# Agent Protocol **Server:** brapi-mcp-server **Version:** 0.11.0 **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 (protocol revisions 2026-07-28 and 2025-*) **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 to do next, what's left, or needs direction, suggest relevant options based on the current project state: 1. **Re-run the `setup` skill** — ensures AGENTS.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`. The framework catches, classifies, and formats. Default to typed contracts: declare `errors: [...]` and throw via `ctx.fail(reason, …)` so failures carry stable `data.reason` codes for agent-client routing. Fall back to error factories (`notFound()`, `validationError()`, etc.) only for services or when no contract entry fits. - **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 — connection bootstrap `brapi_connect` registers a BrAPI server under a named alias, forces a capability refresh, and inlines the full orientation envelope so one call orients the agent. `baseUrl` and `auth` are both `optional()` — when omitted, `resolveConnectInput` fills them from `BRAPI__*` then `BRAPI_DEFAULT_*` env vars, so credentials never enter the LLM context. Same envelope is available on-demand via `brapi_server_info`. Every other connection-scoped tool and resource resolves its connection through `requireRegisteredConnection` (`tools/shared/find-helpers.ts`). An alias with no registration in the caller's scope but a URL of its own — `BRAPI__BASE_URL` (`BRAPI_DEFAULT_BASE_URL` for `default`) or an enabled built-in — is registered on first use as an argument-free `brapi_connect` would register it: token exchange, capability profile (a cached one is reused), then save, with concurrent first calls in one scope sharing a single run (`ServerRegistry.registerOnce`). Any other alias fails `unknown_alias`. The check is deliberately not "`resolveConnectInput` succeeds": that resolver sends an alias with no URL of its own to `BRAPI_DEFAULT_BASE_URL`, so a mistyped alias would silently query the default server. Each caller declares `unknown_alias`, `alias_base_url_invalid`, `private_address_blocked`, `auth_token_exchange_failed`, `auth_no_access_token`, `capability_discovery_unavailable`, `upstream_unauthorized`, and `upstream_forbidden` — the last three also fire on a later call whose cached capability profile expired. A token exchange that stalls fails as a `Timeout` with the upstream-timeout hint, never as `auth_token_exchange_failed`; a base URL or token URL the fetch guard refuses as a private or reserved address fails `private_address_blocked`, caching and registering nothing. ```ts // src/mcp-server/tools/definitions/brapi-connect.tool.ts (abbreviated) import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; import { resolveConnectInput } from '@/config/alias-credentials.js'; import { ConnectAuthSchema } from '../shared/connect-auth-schema.js'; export const brapiConnect = tool('brapi_connect', { description: 'Connect to a BrAPI v2 server… baseUrl + auth fall back to BRAPI__* / BRAPI_DEFAULT_* env vars when omitted.', annotations: { openWorldHint: true, readOnlyHint: false, idempotentHint: true }, errors: [ { reason: 'auth_token_exchange_failed', code: JsonRpcErrorCode.Forbidden, when: 'SGN or OAuth token exchange against /token failed', recovery: 'Verify the credentials and that the server exposes /token before retrying.' }, { reason: 'auth_no_access_token', code: JsonRpcErrorCode.Forbidden, when: 'Token endpoint responded but did not return an access_token', recovery: 'Confirm the credentials are valid and the IdP issues access tokens for this grant.' }, ] as const, input: z.object({ baseUrl: z.string().optional().describe('Falls back to BRAPI__BASE_URL → BRAPI_DEFAULT_BASE_URL.'), auth: ConnectAuthSchema.optional().describe('Falls back to env-derived credentials.'), alias: z.string().regex(/^[a-zA-Z0-9_-]+$/).default('default'), }), output: OrientationEnvelopeSchema, async handler(input, ctx) { // Caller `auth` on session-less shared-tenant HTTP → ctx.fail('auth_session_required') (elided). const resolved = resolveConnectInput(input.alias, { baseUrl: input.baseUrl, auth: input.auth }); // Resolve (token exchange) and fetch a fresh profile before saving: a failed // connect leaves the previous registration under the alias intact. const connection = await getServerRegistry().resolve(ctx, { alias: input.alias, baseUrl: resolved.baseUrl, auth: resolved.auth, }); await getCapabilityRegistry().profile(connection.baseUrl, ctx, { forceRefresh: true, auth: connection.resolvedAuth, }); await getServerRegistry().save(ctx, connection); return buildOrientationEnvelope(ctx, connection, { registry: getCapabilityRegistry(), client: getBrapiClient() }); }, format: (result) => [{ type: 'text', text: formatOrientationEnvelope(result) }], }); ``` ### Tool — find with dataframe spillover `find_*` tools share a pattern: pull one page (usually capped at `loadLimit`; germplasm text scans use configured `pageSize`), walk the remaining pages when it shows more exist (the upstream `totalCount` exceeds it, or, with no `totalCount`, it came back full), compute distributions across the full matched set, and return the leading rows within both `loadLimit` and the 24,000-byte inline budget (`INLINE_RESULTS_BUDGET_BYTES`, via `selectInlineRows`). Whenever `results` holds fewer rows than matched, `maybeSpill` materializes the matched set as a canvas dataframe and returns a handle; `discloseInlineTrim` reports the cut as `truncated` / `shown` / `cap` plus one notice naming the bound that fired and, via `stagedDataframeNotice`, the staged table with `brapi_dataframe_describe` / `brapi_dataframe_query`. Every tool that stages a dataframe carries that pointer in its `notice`. `brapi_find_genotype_calls` already uses `truncated` for its pull cap, so it reports the cut through `shown` / `cap` and `inlineTrimNotice`. Spilled rows live in DuckDB only — there is no parallel JSON store. Canvas is mandatory: `src/index.ts` loads `.env` (`parseConfig()`), defaults an unset or empty `CANVAS_PROVIDER_TYPE` to `duckdb`, and startup fails closed when `core.canvas` is undefined (`CANVAS_PROVIDER_TYPE=none`). CanvasBridge resolves its stored tenant or session pointer through `DataCanvas.acquireShared`, so concurrent first stagings reuse one canvas. Empty genotype matrices pass an explicit column schema through the same registration and provenance path. ```ts // src/mcp-server/tools/definitions/brapi-find-germplasm.tool.ts (abbreviated) export const brapiFindGermplasm = tool('brapi_find_germplasm', { description: 'Find germplasm by name, synonym, accession, PUI, crop, or free-text. results[] holds the leading matches up to loadLimit and a 24,000-byte budget; when it holds fewer rows than matched, the matched set spills to a canvas dataframe — query with brapi_dataframe_query (SQL).', annotations: { readOnlyHint: true, openWorldHint: true }, input: z.object({ alias: AliasInput, names: z.array(z.string()).optional(), crops: z.array(z.string()).optional(), text: z.string().optional(), loadLimit: LoadLimitInput, extraFilters: ExtraFiltersInput, }), output: OutputSchema, async handler(input, ctx) { const connection = await requireRegisteredConnection(ctx, input.alias); await getCapabilityRegistry().ensure(connection.baseUrl, { service: 'germplasm', method: 'GET' }, ctx); const bridge = getCanvasBridge(); const merged = mergeFilters(/* named + extraFilters */, warnings); const { filters } = applyDialectFiltersOrFail(ctx, dialect, 'germplasm', merged, warnings); const route = resolveFindRoute({ profile, dialect, endpoint: 'germplasm', filters, searchBody: merged, warnings }); const firstPage = await loadInitialFindPage(client, connection, route, loadLimit, ctx, input.text ? { pageSize: getServerConfig().pageSize } : {}); const spill = await maybeSpill({ firstPage, client, connection, bridge, warnings, route, path: '/germplasm', filters, source: 'find_germplasm', loadLimit, ctx, }); discloseInlineTrim(ctx, spill, loadLimit); return { results: spill.results, hasMore: spill.hasMore /* + distributions over spill.fullRows + dataframe? */ }; }, format: (result) => [{ type: 'text', text: renderFindResult(result) }], }); ``` ### Raw and SQL previews `brapi_raw_get`, `brapi_raw_search`, and `brapi_dataframe_query` use `selectPreviewRows` to bound both the UTF-8 JSON array and serialized rendered row section to 24,000 bytes, including escaping and markers. These previews return zero rows when the first row is oversized; finders retain their first-row exception through `selectInlineRows`. Raw tools stage every omitted fetched row, preserve envelope siblings, and never auto-walk explicit upstream pages. Query keeps full `registerAs` materialization and typed columns; `shown` is the delivered count and `cap` the effective row ceiling from `CanvasBridge.queryRowLimit`. Ordered SQL scans start at OFFSET 0 and advance by actual delivered counts; zero shown requires a narrower projection or aggregation. The byte bound applies to row representations, not the whole envelope. ### 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'; const ServerConfigSchema = z.object({ defaultBaseUrl: z.string().url().optional(), loadLimit: z.coerce.number().int().positive().default(1_000), maxConcurrentRequests: z.coerce.number().int().positive().default(4), retryMaxAttempts: z.coerce.number().int().min(0).default(3), datasetTtlSeconds: z.coerce.number().int().positive().default(86_400), referenceCacheTtlSeconds: z.coerce.number().int().positive().default(3_600), sessionIsolation: z.enum(['true', 'false']).default('true').transform((v) => v === 'true'), // …see src/config/server-config.ts for the full schema }); let _config: z.infer | undefined; export function getServerConfig() { _config ??= parseEnvConfig(ServerConfigSchema, { defaultBaseUrl: 'BRAPI_DEFAULT_BASE_URL', loadLimit: 'BRAPI_LOAD_LIMIT', maxConcurrentRequests: 'BRAPI_MAX_CONCURRENT_REQUESTS', retryMaxAttempts: 'BRAPI_RETRY_MAX_ATTEMPTS', datasetTtlSeconds: 'BRAPI_DATASET_TTL_SECONDS', referenceCacheTtlSeconds: 'BRAPI_REFERENCE_CACHE_TTL_SECONDS', }); return _config; } ``` `parseEnvConfig` maps Zod schema paths → env var names so validation errors name the actual variable (`BRAPI_LOAD_LIMIT`) rather than the internal path (`loadLimit`). It throws a `ConfigurationError` the framework catches and prints as a clean startup banner. **Per-alias credentials** live in `src/config/alias-credentials.ts`. `readAliasCredentials(alias)` reads `BRAPI__*` (uppercased, hyphens → underscores), `deriveAuthFromCredentials(creds)` derives the auth mode from which fields are set (USERNAME+PASSWORD → `sgn`; BEARER_TOKEN → `bearer`; API_KEY → `api_key`; OAUTH_CLIENT_ID+SECRET → `oauth2`; mixing families raises `ValidationError`), and `resolveConnectInput(alias, agentInput)` layers agent input → alias env → default env → no-auth fallback. Env credentials only travel to the URL configured alongside them: an alias's credentials pair with its own `BRAPI__BASE_URL`, else its enabled built-in URL, and a caller `baseUrl` that differs is refused (`auth_base_url_mismatch`); credentials with neither pair with nothing and the connect is refused (`alias_base_url_unset`, `ConfigurationError`), never falling back to `BRAPI_DEFAULT_BASE_URL`. `BRAPI_DEFAULT_*` credentials attach only when the resolved URL is `BRAPI_DEFAULT_BASE_URL`. `discoverConfiguredAliases` reports each alias's `authMode` by running the same resolver. --- ## Context Handlers receive a unified `ctx` object. Currently used surface: | 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 — used by `ServerRegistry` (connection aliases), `CanvasBridge` (default canvas pointer + per-table provenance), and `CapabilityRegistry` (cached profiles). Spilled `find_*` rows live on the canvas (DuckDB), not in `ctx.state`. | | `ctx.sessionId` | Mcp-Session-Id (HTTP stateful/auto); `undefined` for stdio, stateless HTTP unless `exposeStatelessSessionId` is opted in, and every request on protocol revision 2026-07-28, which is session-less by design. Composed into `ServerRegistry.connKey` and `CanvasBridge.defaultCanvasKey` when `BRAPI_SESSION_ISOLATION=true` (default), so concurrent HTTP sessions in the same tenant don't share connection state or canvas. Discovery / scoping key on top of tenant-keyed state — not an authorization principal. | | `ctx.signal` | `AbortSignal` — threaded into every BrAPI HTTP call so client-side cancellation aborts the upstream request. | | `ctx.requestId` | Unique request ID — auto-attached to every `ctx.log` entry. | | `ctx.tenantId` | Tenant ID from JWT or `'default'` for stdio / HTTP+`auth=none` — outer scope on all `ctx.state` reads/writes. | | `ctx.requestInput` / `ctx.inputs` | Multi-round-trip input. `brapi_submit_observations` gates apply-mode writes on a `confirm` elicitation behind a consent record (`api-context` § Consent gates): asking stores `{ operation, clientId, subject, target, contentHash }` in `ctx.state` under `brapi/consent/` (600 s TTL) and returns the id as `requestState`; each call first redeems (reads and deletes) the record its `requestState` names. An answer counts only when that record matches this caller, base URL + `studyDbId`, and the SHA-256 of the rows — anything else asks again. On the matching round a declined, cancelled, or unparseable answer is terminal (`user_declined`). `force: true` skips the round. Redemption is single-use against a sequential replay only (no atomic `ctx.state.take` yet), and a multi-instance HTTP deployment needs shared storage (`filesystem`, `supabase`, `cloudflare-d1`) for the record. | | `ctx.enrich` | Success-path agent context. `brapi_dataframe_query`, `brapi_build_phenotype_matrix`, and the seven filter finders (through `discloseInlineTrim`) disclose capped results with `ctx.enrich.truncated({ shown, cap, guidance })`. | `ctx.fail(reason, …)` is the typed thrower keyed off declared `errors[]` contracts — declared by every tool except `brapi_dataframe_drop` (24) and all 6 resources today. The framework fills the matching contract entry's recovery hint into `data.recovery.hint` on the wire, for service throws carrying `data.reason` too. `brapi_get_image` emits bytes once through `ctx.content.image(data, mimeType)`; structured output and `format()` carry metadata only. Both byte sources normalize MIME case/parameters and require `image/*`, using metadata only when response MIME is absent; non-image bytes are per-ID errors, preserving successful siblings. `brapi_walk_pedigree` keeps graph-terminal `leafCount` separate from `confirmedLeafCount` (successful empty lookups on all reached sides). Its bounded `frontier` identifies unresolved node/side outcomes; large frontiers stage separately. Nodes and edges each use `selectPreviewRows` with their actual renderer, retaining the discovered graph when trimmed. Safety-capped graph handles/provenance declare partial coverage; diagrams use only that discovered graph. Fetched omitted relatives stage separately with ordinal, expansionNodeDbId and side. SQL retrieval projects IDs/side, orders by ordinal, starts at OFFSET 0 and advances by actual delivered rows; zero rows requires a narrower projection or aggregation. Unknown unrequested-relative counts remain unknown; there is no automatic continuation. --- ## Errors Handlers throw — the framework catches, classifies, and formats. **Default for new tools: 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 )`. Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata. A direct `definition.handler(...)` call in a test sees the throw site's error with no fill; assert hints through `runToolContract` (tools) or the resource factory (`tests/resources/_resource-factory.ts`). Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring. Live across the BrAPI surface today: every tool except `brapi_dataframe_drop`, plus all six `brapi://` resources. ```ts errors: [ { reason: 'unknown_alias', code: JsonRpcErrorCode.NotFound, when: 'No connection registered for this alias', recovery: 'Call brapi_connect with this alias before retrying.' }, ], async handler(input, ctx) { const conn = registry.peek(input.alias); if (!conn) throw ctx.fail('unknown_alias', `No connection for ${input.alias}`); // ... } ``` **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 (input, output, errors, handler, format). Don't extract a shared `errors[]` constant or contract module to deduplicate near-identical entries; per-tool repetition is the intended cost of locality, and dynamic `recovery` hints often need tool-specific runtime context anyway. **Fallback (no contract entry fits, services, prototype tools):** throw via factories or plain `Error`. ```ts // Plain Error — framework auto-classifies from message patterns throw new Error('Item not found'); // → NotFound throw new Error('Invalid query format'); // → ValidationError // Error factories — explicit code, concise import { notFound, validationError, internalError, serviceUnavailable } from '@cyanheads/mcp-ts-core/errors'; throw notFound('Item not found', { itemId }); throw serviceUnavailable('API unavailable', { url }, { cause: err }); // McpError — full control over code and data import { McpError, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; throw new McpError(JsonRpcErrorCode.DatabaseError, 'Connection failed', { pool: 'primary' }); ``` Available factories include `notFound`, `validationError`, `forbidden`, `unauthorized`, `serviceUnavailable`, `rateLimited`, `timeout`, `conflict`, `internalError`, `serializationError`, `databaseError`, `configurationError`, `invalidParams`, `invalidRequest`. See framework CLAUDE.md for the full auto-classification table and the `api-errors` skill for contract patterns. --- ## Structure ```text src/ index.ts # createApp() entry point — registers 25 tools, 6 resources, 2 prompts; inits 7 services config/ server-config.ts # BRAPI_* env vars (Zod schema, lazy-parsed) alias-credentials.ts # Per-alias env-var resolution (BRAPI__*) for brapi_connect services/ brapi-client/ # HTTP client — retry, concurrency cap, async-search poll, private-IP guard, binary fetch, POST/PUT brapi-dialect/ # Per-server filter / payload adapters (spec, cassavabase) — translates plural→singular, drops searchText, declares known-dead POST /search routes; envelope surfaces id + source + disabled-search nouns brapi-filters/ # Static v2.1 filter catalog canvas-bridge/ # Default-canvas resolver (per-session when BRAPI_SESSION_ISOLATION=true; per-tenant otherwise), df_ table generator, provenance store capability-registry/ # Per-connection /serverinfo cache + call guard iso-country/ # ISO 3166-1 resolver — names and aliases → alpha-3, alpha-2, numeric codes ontology-resolver/ # Free-text → ontology-term matcher for variables reference-data-cache/ # Programs / trials / locations / crops lookup cache server-registry/ # Alias → live connection map with auth resolution; session-scoped under BRAPI_SESSION_ISOLATION=true mcp-server/ tools/ definitions/ brapi-connect.tool.ts # Session bootstrap — auth, capability load, orientation envelope brapi-server-info.tool.ts # Orientation envelope on demand brapi-describe-filters.tool.ts # Static BrAPI v2.1 filter catalog lookup brapi-find-studies.tool.ts # find_* — studies, distributions + spillover brapi-get-study.tool.ts # get_* — study + FK resolution + companion counts brapi-find-germplasm.tool.ts # find_* — germplasm brapi-get-germplasm.tool.ts # get_* — germplasm + attributes + parents + companion counts brapi-walk-pedigree.tool.ts # BFS DAG walk (ancestors / descendants / both), one edge per pair, loop-closing edges marked, optional diagram brapi-find-variables.tool.ts # find_* — observation variables, free-text ranking via OntologyResolver brapi-find-observations.tool.ts # find_* — observation records brapi-find-images.tool.ts # find_* — image metadata brapi-get-image.tool.ts # Fetch image bytes inline (imagecontent → imageURL fallback) brapi-find-locations.tool.ts # find_* — locations, optional client-side bbox filter brapi-find-variants.tool.ts # find_* — variants, 0-based [start, end) genomic region brapi-find-genotype-calls.tool.ts # Async-search genotype calls with maxCalls cap + dataframe spillover brapi-dataframe-describe.tool.ts # List / describe canvas dataframes with columns, row counts, provenance brapi-dataframe-query.tool.ts # Run read-only SQL across canvas dataframes brapi-dataframe-drop.tool.ts # Drop a dataframe by name (opt-in via BRAPI_CANVAS_DROP_ENABLED) brapi-dataframe-export.tool.ts # Write CSV/Parquet/JSON to BRAPI_EXPORT_DIR (opt-in, stdio-only) brapi-build-phenotype-matrix.tool.ts # Germplasm × trait matrix from studies; materialized as canvas dataframe brapi-germplasm-performance.tool.ts # Per-variable aggregates (n, mean, median, sd) for a single germplasm brapi-export-genotype-matrix.tool.ts # Genotype calls → germplasm × variant dataframe + VCF-lite / PLINK serialization brapi-submit-observations.tool.ts # Two-phase observation write — preview / apply (POST + PUT) behind a confirmation round trip brapi-raw-get.tool.ts # Last-resort GET passthrough with routing nudge brapi-raw-search.tool.ts # Last-resort POST /search passthrough with async polling shared/ connect-auth-schema.ts # Tagged-union auth input orientation-envelope.ts # Shared envelope builder + formatter find-helpers.ts # Alias / loadLimit / extraFilters fragments, mergeFilters, maybeSpill, DataframeHandleSchema raw-routing-hints.ts # Routing nudges emitted by raw_get / raw_search when a curated tool exists canvas-columns.ts # SQL-safe column-name sanitizer + variableLegend builder (shared by matrix tools) observations.ts # Study-anchored observation pull shared by phenotype-matrix and germplasm-performance genotype-calls.ts # Async genotype-call collector shared by find-genotype-calls and export-genotype-matrix pedigree-diagram.ts # Pure Mermaid / DOT renderer for walk_pedigree results (label escaping, Mermaid limits) resources/ definitions/ brapi-server-info.resource.ts # brapi://server/info — orientation envelope (default connection) brapi-calls.resource.ts # brapi://calls — raw capability profile brapi-study.resource.ts # brapi://study/{studyDbId} — single study with FKs brapi-germplasm.resource.ts # brapi://germplasm/{germplasmDbId} — single germplasm with attributes + parents brapi-filters.resource.ts # brapi://filters/{endpoint} — filter catalog brapi-variable.resource.ts # brapi://variable/{observationVariableDbId} — observation variable (trait, scale, method) prompts/ definitions/ brapi-eda-study.prompt.ts # EDA playbook for one study (orient → variables → coverage → outliers → report) brapi-meta-analysis.prompt.ts # Cross-study meta-analysis (resolve trait → discover studies → harmonize → summarize) ``` --- ## Naming | What | Convention | Example | |:-----|:-----------|:--------| | Files | kebab-case with suffix | `search-docs.tool.ts` | | Tool/resource/prompt names | snake_case | `search_docs` | | Directories | kebab-case | `src/services/doc-search/` | | Descriptions | Single string or template literal, no `+` concatenation | `'Search items by query and filter.'` | --- ## Skills Read development skills in `framework-skills/` when a task matches. `bun run list-skills` prints the registry. Root `skills/` is reserved for skills published to agents that install or call this server. The npm package, Docker image, and MCPB bundle ship the published tree when present. To serve it over MCP, set `skills: 'skills'` in `createApp()`. The packaging check validates the published tree. **Agent skill directory:** Copy skills into the directory your agent discovers (Claude Code: `.claude/skills/`, Codex: `.codex/skills/`, shared: `.agents/skills/`, others: equivalent). This makes skills available as context without needing to reference `framework-skills/` paths manually. After framework updates, run the `maintenance` skill — it re-syncs the agent directory automatically (Phase B). 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 published skill for callers in root `skills/` | | `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 | | `techniques` | Catalog of response/data-shaping techniques — overflow handling, payload shaping, retrieval patterns | | `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 | | `api-auth` | Auth modes, scopes, JWT/OAuth | | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` | | `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-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-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 | **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 and the production entry point use Bun directly — Bun executes TypeScript natively, no `tsx` shim. The `packageManager` field pins the version. | 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 list-skills` | Print the skill index for this project (name, version, description) | | `bun run tree` | Generate `docs/tree.md` | | `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` | Run the MCP definition linter standalone (rule catalog: `api-linter` skill) | | `bun run lint:packaging` | Check env-var parity, Dockerfile build platform, MCPB exclusions, plugin manifests, and published skills | | `bun run bundle` | Build, pack, and clean a `.mcpb` for one-click Claude Desktop install | | `bun run test` | Run tests (Vitest — use `bun run test`, not `bun test`) | | `bun run start` | Production mode — defers transport selection to `MCP_TRANSPORT_TYPE` (stdio default) | | `bun run start:stdio` | Production mode (stdio) — requires prior `bun run build` | | `bun run start:http` | Production mode (HTTP) — requires prior `bun run build` | | `bun run changelog:build` | Regenerate `CHANGELOG.md` from `changelog/.x/` | | `bun run changelog:check` | Verify `CHANGELOG.md` is in sync (used by devcheck) | **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 a self-contained `.mcpb` for Claude Desktop. After `mcpb clean`, `scripts/clean-mcpb.ts` removes dependency-shipped agent docs, node-gyp intermediates, and build-only files, then includes every native platform package declared by the installed `@duckdb/node-bindings` manifest. `scripts/mcpb-duckdb-bindings.ts` supplies the inventory to the cleaner and packaging linter; the linter requires each package's manifest, native addon, and shared library. DuckDB is mandatory for BrAPI's staged results. Preserve this packaging behavior when syncing framework scripts: stripping all native bindings leaves a server that starts but cannot stage data, and setting `CANVAS_PROVIDER_TYPE=none` prevents startup. Verify the extracted bundle by staging rows and querying their dataframe, not only initializing MCP. The GitHub release asset carries the complete native inventory; `server.json` registers the npm package. MCPB runs over stdio. **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. --- ## Changelog Directory-based, grouped by minor series using the `.x` semver-wildcard convention. Source of truth is `changelog/.x/.md` (e.g. `changelog/0.1.x/0.1.0.md`) — one file per released version, shipped in the npm package. At release time, 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, never renamed, never moved. Read it to remember the frontmatter + section layout when scaffolding a new per-version file. `CHANGELOG.md` is a **navigation index** (header + link + one-line summary per version), regenerated by `npm run changelog:build`. Devcheck hard-fails on drift. Never hand-edit `CHANGELOG.md`. 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** (Keep a Changelog): Added, Changed, Deprecated, Removed, Fixed, Security, then Dependencies. Include only sections with entries — don't ship empty headers. **Tag annotations** render as GitHub Release bodies via `--notes-from-tag`. Subject omits the version number (GitHub prepends it). Follow `framework-skills/release-and-publish/SKILL.md` for the tag format. --- ## 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/`, 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. --- ## 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 { getMyService } from '@/services/my-domain/my-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 schema-level regex/length matters, 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 — no `console`, no direct persistence access - [ ] 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 - [ ] BrAPI tool: resolves connection via `requireRegisteredConnection(ctx, input.alias)` before touching the client, and declares the reasons that resolution can raise: `unknown_alias`, `alias_base_url_invalid`, `private_address_blocked`, `auth_token_exchange_failed`, `auth_no_access_token`, `capability_discovery_unavailable`, `upstream_unauthorized`, `upstream_forbidden` - [ ] BrAPI tool: gates the call with `CapabilityRegistry.ensure(...)` — never fires against an endpoint the server didn't advertise - [ ] BrAPI tool: raw / domain / output schemas reviewed against real upstream sparsity (most `/germplasm` and `/studies` fields are optional in the wild) - [ ] BrAPI tool: normalization and `format()` preserve uncertainty — never fabricate missing IDs, names, or counts - [ ] BrAPI tool with dataframe spillover: `results` is cut by `loadLimit` and the 24,000-byte inline budget (`selectInlineRows`); whenever it holds fewer rows than matched, the matched set materializes as a `df_` canvas table via `CanvasBridge.registerDataframe`, the handle surfaces in `result.dataframe`, the cut is disclosed (`truncated` / `shown` / `cap` + notice), and `hasMore` is set correctly - [ ] Tests include at least one sparse upstream payload (fields omitted) alongside the happy path - [ ] Registered in the `tools` array of `createApp()` in `src/index.ts` - [ ] 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 - [ ] `.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.