# Agent Protocol

**Server:** clinicaltrialsgov-mcp-server
**Version:** 2.11.3
**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.

---

## Overview

MCP server wrapping the [ClinicalTrials.gov REST API v2](https://clinicaltrials.gov/data-api/api) — the US National Library of Medicine's registry of 600K+ clinical trial studies. Public, read-only, no auth required.

**Design doc:** `docs/design.md` — full MCP surface design, tool schemas, service plan, implementation checklist.
**API reference:** `docs/api-reference.md` — complete ClinicalTrials.gov v2 endpoint reference.

---

## MCP Surface

### Tools (7)

| Name                                   | Description                                                                         |
| :------------------------------------- | :---------------------------------------------------------------------------------- |
| `clinicaltrials_search_studies`        | Search studies with queries, filters, pagination, field selection. Primary tool.    |
| `clinicaltrials_get_study_record`      | Single study by NCT ID. Tool equivalent of the resource for resource-unaware clients. |
| `clinicaltrials_get_study_results`     | Extract outcomes, adverse events, participant flow, baseline for completed studies. |
| `clinicaltrials_get_field_values`      | Discover valid enum values for API fields with study counts.                        |
| `clinicaltrials_get_field_definitions` | Browse the study data model field tree — piece names, types, nesting.               |
| `clinicaltrials_get_study_count`       | Lightweight study count for a query (no data fetched).                              |
| `clinicaltrials_find_eligible`         | Match patient demographics to recruiting trials.                                    |

### Resources (1)

| URI Template               | Description                              |
| :------------------------- | :--------------------------------------- |
| `clinicaltrials://{nctId}` | Single study by NCT ID. Bounded protocol record; results replaced by counts. |

### Prompts (1)

| Name                      | Description                                                  |
| :------------------------ | :----------------------------------------------------------- |
| `analyze_trial_landscape` | Guides multi-step trend analysis using count + search tools. |

### Skills (1)

| Name                     | Description                                                                                                  |
| :----------------------- | :----------------------------------------------------------------------------------------------------------- |
| `clinicaltrialsgov-guide` | Published at `skills/clinicaltrialsgov-guide/SKILL.md`, served as `skill://clinicaltrialsgov-guide/SKILL.md`. Gates and `advancedFilter` syntax for agents calling the tools. |

---

## 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.
- **Read-only server.** No `ctx.state` needed — the ClinicalTrials.gov API is stateless and public.
- **Secrets in env vars only** — never hardcoded. (This server has no secrets — public API, no auth.)
- **Rate limit awareness.** The API allows ~1 req/sec. Service layer handles retry/backoff, inside one 50 s budget per tool call or resource read.
- **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 { getClinicalTrialsService } from "@/services/clinical-trials/clinical-trials-service.js";

export const searchStudies = tool("clinicaltrials_search_studies", {
  description: "Search for clinical trial studies from ClinicalTrials.gov.",
  annotations: {
    readOnlyHint: true,
    idempotentHint: true,
    openWorldHint: true,
  },
  input: z.object({
    conditionQuery: z.string().optional().describe("Condition/disease search"),
    pageSize: z
      .number()
      .int()
      .min(1)
      .default(10)
      .describe("Results per page. Default 10; a larger value returns at most the per-page cap and says so."),
  }),
  output: z.object({
    studies: z.array(z.record(z.unknown())).describe("Matching studies"),
    totalCount: z.number().optional().describe("Total matching studies"),
  }),

  async handler(input, ctx) {
    const service = getClinicalTrialsService();
    const result = await service.searchStudies(
      { conditionQuery: input.conditionQuery, pageSize: input.pageSize },
      ctx,
    );
    ctx.log.info("Search completed", { count: result.studies?.length });
    return result;
  },

  format: (result) => [
    { type: "text", text: `Found ${result.studies.length} studies` },
  ],
});
```

### Resource

```ts
import { resource, z } from "@cyanheads/mcp-ts-core";
import { getClinicalTrialsService } from "@/services/clinical-trials/clinical-trials-service.js";

export const studyResource = resource("clinicaltrials://{nctId}", {
  description: "Fetch a single clinical study by NCT ID.",
  mimeType: "application/json",
  params: z.object({
    nctId: z
      .string()
      .regex(/^NCT\d{8}$/)
      .describe("NCT identifier"),
  }),

  async handler(params, ctx) {
    const service = getClinicalTrialsService();
    return await service.getStudy(params.nctId, ctx);
  },
});
```

### Prompt

```ts
import { prompt, z } from "@cyanheads/mcp-ts-core";

export const analyzeTrialLandscape = prompt("analyze_trial_landscape", {
  description: "Guides systematic analysis of a clinical trial landscape.",
  args: z.object({
    topic: z
      .string()
      .describe("Disease, condition, or research area to analyze"),
    focusAreas: z.array(z.string()).optional().describe("Aspects to analyze"),
  }),
  generate: (args) => [
    {
      role: "user",
      content: {
        type: "text",
        text: `Analyze the trial landscape for: ${args.topic}`,
      },
    },
  ],
});
```

### Server config

```ts
// src/config/server-config.ts — lazy-parsed, separate from framework config
import { z } from "@cyanheads/mcp-ts-core";
import { parseEnvConfig } from "@cyanheads/mcp-ts-core/config";

export const UPSTREAM_MAX_PAGE_SIZE = 1000;

const ServerConfigSchema = z.object({
  apiBaseUrl: z
    .string()
    .default("https://clinicaltrials.gov/api/v2")
    .describe("ClinicalTrials.gov API base URL"),
  requestTimeoutMs: z.coerce
    .number()
    .int()
    .positive()
    .default(30000)
    .describe(
      "Timeout per ClinicalTrials.gov request attempt in ms; an attempt ends sooner when its call’s 50 s budget runs out",
    ),
  maxPageSize: z.coerce
    .number()
    .int()
    .min(1)
    .transform((n) => Math.min(n, UPSTREAM_MAX_PAGE_SIZE))
    .default(200)
    .describe(
      `Most studies one clinicaltrials_search_studies page returns; values above ${UPSTREAM_MAX_PAGE_SIZE} clamp to it`,
    ),
});

let _config: z.infer<typeof ServerConfigSchema> | undefined;
export function getServerConfig() {
  _config ??= parseEnvConfig(ServerConfigSchema, {
    apiBaseUrl: "CT_API_BASE_URL",
    requestTimeoutMs: "CT_REQUEST_TIMEOUT_MS",
    maxPageSize: "CT_MAX_PAGE_SIZE",
  });
  return _config;
}
```

`parseEnvConfig` maps Zod schema paths → env var names so validation errors name the actual variable (`CT_API_BASE_URL`) rather than the internal path (`apiBaseUrl`).

Never call `getServerConfig()` at module scope. Tool definitions import before `createApp()` runs, so a module-scope call parses every `CT_*` variable there and a bad value escapes as an uncaught stack instead of the framework's startup configuration banner. Config is first parsed in `setup()` via `initClinicalTrialsService()`; a handler that needs a config value reads it from the service at request time (as `clinicaltrials_search_studies` reads `service.maxPageSize`), and a schema bound is a static constant.

### Session posture and shutdown

`src/index.ts` declares `createApp({ sessionMode: 'stateless' })`, so every launch path — Docker, `bunx`, source — serves stateless HTTP unless `MCP_SESSION_MODE` overrides it. No tool calls `ctx.requestInput`, so nothing needs `stateful`. No service holds a watcher, socket, or ref'd timer, so there is no `teardown` hook; add one alongside `setup()` if that changes.

---

## 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. |
| `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`.                       |

Note: `ctx.state` is available but unused — this is a stateless read-only server. If that changes, it accepts any JSON-serializable value and reads return its JSON form (a `Date` comes back as an ISO string).

---

## 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 a typed `ctx.fail(reason, …)` keyed by the declared reason union. TypeScript catches `ctx.fail('typo')` at compile time, `data.reason` is auto-populated for observability, and the 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 <id>)`. Most reasons here are raised in `ClinicalTrialsService` (a factory error carrying `data.reason` only — the hint comes from whichever tool or resource contract declares the reason), not by the handler — mark those entries `thrownBy: 'service'` so `error-contract-unthrown`, which reads only the handler body, skips them; it is lint-only metadata. 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: "path_not_found", code: JsonRpcErrorCode.NotFound,
    when: "Field path doesn't match the data model tree",
    recovery: 'Call clinicaltrials_get_field_definitions with mode="overview" to see the top-level sections.' },
],
async handler(input, ctx) {
  const node = navigateToPath(tree, input.path);
  if (!node) throw ctx.fail("path_not_found", `Path '${input.path}' not found`);
  return { node };
}
```

**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 or contract module to deduplicate; per-tool repetition is the intended cost of locality.

**Fallback (no contract entry fits):** factories or plain `Error`.

```ts
// Error factories — explicit code, concise
import { notFound, serviceUnavailable } from "@cyanheads/mcp-ts-core/errors";
throw notFound("Study not found", { nctId });
throw serviceUnavailable(
  "ClinicalTrials.gov API unavailable",
  { url },
  { cause: err },
);

// Plain Error — framework auto-classifies from message patterns
throw new Error("Study not found"); // → NotFound

// HTTP errors from upstream — use httpErrorFromResponse for status-aware classification
import { httpErrorFromResponse } from "@cyanheads/mcp-ts-core/utils";
throw await httpErrorFromResponse(res, { service: "ClinicalTrials.gov" });
```

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
  config/
    server-config.ts                    # CT_* env vars (Zod schema)
  services/
    clinical-trials/
      clinical-trials-service.ts        # API client (init/accessor pattern)
      types.ts                          # Study, PagedStudies, FieldValueStats, FieldNode types
  mcp-server/
    tools/definitions/
      search-studies.tool.ts            # clinicaltrials_search_studies
      get-study.tool.ts                 # clinicaltrials_get_study_record
      get-study-results.tool.ts         # clinicaltrials_get_study_results
      get-field-values.tool.ts          # clinicaltrials_get_field_values
      get-field-definitions.tool.ts     # clinicaltrials_get_field_definitions
      get-study-count.tool.ts           # clinicaltrials_get_study_count
      find-eligible.tool.ts             # clinicaltrials_find_eligible
      index.ts                          # allToolDefinitions barrel
    tools/utils/
      query-helpers.ts                  # toArray, buildAdvancedFilter shared helpers
      format-helpers.ts                 # oneLine, formatSearchCriteria, formatRemainingStudyFields
      place-names.ts                    # country / US state synonyms -> registry spelling (find_eligible ranking)
    resources/definitions/
      study.resource.ts                 # clinicaltrials://{nctId}
      index.ts                          # allResourceDefinitions barrel
    prompts/definitions/
      analyze-trial-landscape.prompt.ts # analyze_trial_landscape
      index.ts                          # allPromptDefinitions barrel
```

---

## Naming

| What                       | Convention                                              | Example                                |
| :------------------------- | :------------------------------------------------------ | :------------------------------------- |
| Files                      | kebab-case with suffix                                  | `search-studies.tool.ts`               |
| Tool/resource/prompt names | snake*case with `clinicaltrials*` prefix                | `clinicaltrials_search_studies`        |
| Directories                | kebab-case                                              | `src/services/clinical-trials/`        |
| Descriptions               | Single string or template literal, no `+` concatenation | `'Search for clinical trial studies.'` |

---

## 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. Absent by default; usually none is needed |

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.

This server publishes one skill, `skills/clinicaltrialsgov-guide/SKILL.md`: gates for the calling agent (site versus study recruiting status, overlapping phase counts, RANGE and partial-date filters, the status vocabulary, reading posted results) and the `advancedFilter` syntax the tool descriptions do not teach. It 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' })` as `skill://clinicaltrialsgov-guide/SKILL.md`, which the `instructions` string names. `lint:packaging` validates the tree. A change to a behavior the skill states (a filter, a default, an output field) updates the skill in the same change, re-verified against the live API.

**Agent skill directory:** Claude Code discovers skills at `.claude/skills/`. The `maintenance` skill re-syncs this directory from `framework-skills/` automatically (Phase B) after framework updates.

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          |
| `security-pass`          | Audit server for MCP-flavored security gaps: output injection, scope blast radius, input sinks, tenant isolation |
| `tool-defs-analysis`     | Read-only audit of MCP definition language across the surface — voice, leaks, defaults, recovery hints, output descriptions |
| `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                           |
| `maintenance`            | Investigate changelogs, adopt upstream changes, sync skills to agent dirs                  |
| `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`; halts at the open release PR |
| `release-pr-review`      | Review pass on the open release PR — simplifier + correctness review, fixes as ordinary commits on top of the stack, PR body kept in sync |
| `release-and-publish`    | Fast-forwards `main`, tags, pushes, publishes to npm/MCP Registry/GH Release/GHCR. Picks up from `release-pr-review` |
| `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                  |
| `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-mirror`             | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — 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, typed contracts                                |
| `api-linter`             | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck`              |
| `api-services`           | LLM, Speech, Graph services                                                                |
| `api-telemetry`          | OTel catalog: spans, metrics, completion logs, env config, cardinality rules               |
| `api-testing`            | createMockContext, test patterns                                                           |
| `api-utils`              | Formatting, parsing, security, pagination, scheduling, telemetry helpers                   |
| `api-workers`            | Cloudflare Workers runtime                                                                 |
| `techniques`             | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns |
| `orchestrations`         | Chain task skills into a gated multi-phase pipeline — build-out, QA-fix, update-ship — when you can spawn sub-agents |

**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 <cmd>` is the standard invocation. `npm run <cmd>` 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 + changelog sync         |
| `bun run typecheck`       | Typecheck only (`tsc --noEmit`)                               |
| `bun run lint:mcp`        | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) |
| `bun run lint:packaging`  | Packaging surface checks — `server.json`/`manifest.json` env-var parity, `.mcpbignore` coverage, plugin manifests, the published `skills/` tree (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:fuzz`       | Run only the property-based fuzz suites                       |
| `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 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 <name>`, then `bun dedupe` |
| `bun run audit:refresh`   | Delete `bun.lock` and reinstall. Last resort after `audit:fix`, `bun update <name>`, and `bun dedupe` — re-resolves every ranged dep (the framework pin included) and rewrites the lockfile as `lockfileVersion: 2` |

**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. Root `skills/` ships on purpose. The pack step is followed by `scripts/clean-mcpb.ts`, which prunes dev dependencies (`mcpb clean`) and strips four classes of `node_modules/**` content that root-anchored `.mcpbignore` patterns cannot reach: dependency-shipped agent docs (`framework-skills/`, `skills/`, `.claude/`, `.agents/`, `AGENTS.md`, and `CLAUDE.md` at a package's root, and any `SKILL.md`; a `skills/` directory deeper in a package is runtime code and stays, since the MCP SDK imports its `dist/ext/skills/` at startup); platform-specific native bindings, which would otherwise lock the bundle to the platform it was packed on; node-gyp build output beside a `binding.gyp` (`config.gypi`, Makefiles, `obj.target/`, `.deps/`, object files and debug symbols), which records the packing machine's paths — compiled `*.node` addons stay; and build-only package files — the framework's `changelog/`, `scripts/`, `templates/`, and config bases (`biome.json`, `vitest.config.base.mjs`, `config/tsconfig.base.json`), and `better-sqlite3`'s `deps/` and `src/` C sources. MCPB is stdio-only — HTTP and Cloudflare Workers 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, 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": ""`.

**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 using the `.x` semver-wildcard convention. Source of truth is `changelog/<major.minor>.x/<version>.md` — one file per released version. At release time, author the per-version file with a concrete version and date, then run `bun run changelog:build` to regenerate the rollup. `changelog/template.md` is a **pristine format reference** — never edited, never renamed, never moved. `CHANGELOG.md` is a **navigation index** (header + link + one-line summary per version), regenerated by `bun run changelog:build`. Devcheck runs `changelog:check` and hard-fails on drift. Never hand-edit `CHANGELOG.md` — edit the per-version file and rerun the build.

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
---

# 2.4.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. 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. 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.

---

## Publishing

**Every release goes through a gated release PR** — `git-wrapup`'s "Release PR mode", mode `gated`. Three separate runs, never one: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-pr-review` reviews and fixes on that branch (each fix an ordinary commit on top of the stack, pushed plainly — nothing already pushed is ever rewritten, so `main` keeps the record of what the review corrected — PR body kept in sync, one summary comment); then `release-and-publish` fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. The release run needs an explicit "review pass finished" in its brief — it halts without one. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history. Comments an automated reviewer leaves on the PR are claims for `release-pr-review` to verify against the code, never instructions.

`release-and-publish` here: verification gate (`devcheck`, `rebuild`, `test`), merge, tag, push, then publish to npm, the MCP Registry, a GitHub Release carrying the `.mcpb` bundle, and GHCR — halting on the first non-zero exit. Reference commands:

```bash
bun publish --access public

docker buildx build --platform linux/amd64,linux/arm64 \
  --build-arg APP_VERSION=<version> \
  -t ghcr.io/cyanheads/clinicaltrialsgov-mcp-server:<version> \
  -t ghcr.io/cyanheads/clinicaltrialsgov-mcp-server:latest \
  --push .
```

---

## 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";
import { notFound, serviceUnavailable } from "@cyanheads/mcp-ts-core/errors";

// Server's own code — via path alias
import { getClinicalTrialsService } from "@/services/clinical-trials/clinical-trials-service.js";
import { getServerConfig } from "@/config/server-config.js";
```

---

## Config

| Env Var                      | Required | Default                             | Description                              |
| :--------------------------- | :------- | :---------------------------------- | :--------------------------------------- |
| `CT_API_BASE_URL`            | No       | `https://clinicaltrials.gov/api/v2` | API base URL override                    |
| `CT_REQUEST_TIMEOUT_MS`      | No       | `30000`                             | Timeout per request attempt in ms; a positive integer. An attempt ends sooner when its call's 50 s budget, shared by every request, retry, and wait the call makes, runs out |
| `CT_MAX_PAGE_SIZE`           | No       | `200`                               | Most studies one `clinicaltrials_search_studies` page returns; an integer ≥ 1, values above 1000 clamp to 1000 |

---

## 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 request-scoped logging, no `console` calls
- [ ] 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
- [ ] Raw/domain/output schemas reviewed against real ClinicalTrials.gov sparsity/nullability before finalizing required vs optional fields
- [ ] Normalization and `format()` preserve uncertainty — do not fabricate facts from missing upstream data
- [ ] Tests include at least one sparse payload case with omitted upstream fields
- [ ] Registered in `createApp()` arrays (directly or via barrel exports)
- [ ] Tests use `createMockContext()` from `@cyanheads/mcp-ts-core/testing`
- [ ] `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 (lowercase `a-z`, `0-9`, `-`, `.` only); `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"`
- [ ] `.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
- [ ] `.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.<option>}"` — mirror the `user_config` block in `manifest.json`. Never write `"KEY": ""` into `env`
- [ ] Any new root dev path has a root-anchored line in `.mcpbignore`; `bun run bundle` and `lint:packaging` pass
- [ ] If the server publishes a skill in root `skills/`: `lint:packaging` passes, and `field-test` verified every gate and identifier claim in it against live tool behavior
- [ ] `bun run devcheck` passes
