# Developer Protocol **Server:** faostat-mcp-server **Version:** 0.7.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 (via the framework) **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. **Add tools** — scaffold new definitions using the `add-tool` / `add-app-tool` skills 2. **Add a domain to the mirror** — extend `FAOSTAT_DOMAINS`, then re-sync (`bun run mirror:refresh`) so `faostat_query_observations` can read it 3. **Add tests** — scaffold tests for existing definitions using the `add-test` skill 4. **Field-test definitions** — exercise tools with real inputs using the `field-test` skill, get a report of issues and pain points 5. **Run `devcheck`** — lint, format, typecheck, and security audit 6. **Run the `security-pass` skill** — audit handlers for MCP-specific security gaps: output injection, scope blast radius, input sinks, tenant isolation 7. **Run the `polish-docs-meta` skill** — finalize README, CHANGELOG, metadata, and agent protocol for shipping 8. **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 handlers are pure — throw on failure, no `try/catch`. Plain `Error` is fine; the framework catches, classifies, and formats. Use the typed `errors[]` contract + `ctx.fail()` (or factories like `notFound()`) when the error code matters. - **Use `ctx.log`** for request-scoped logging. No `console` calls (the mirror CLI scripts are the lone exception — they print progress to stdout outside a request). - **Use `ctx.state`** for tenant-scoped storage. Never access persistence directly. The canvas staging layer resolves the session canvas from `ctx.state`. - **Secrets in env vars only** — never hardcoded. (This server is keyless; there are no secrets to manage.) - **Carry the data-quality flag through.** Every FAOSTAT observation has a flag (`A`/`E`/`I`/`B`/`X`). Never drop it from output or `format()` — it's load-bearing for downstream rigor. - **Area aggregate exclusion is the default.** `faostat_query_observations` excludes area codes ≥ 5000 and curated rollups unless `include_aggregates: true` or explicit `area_codes` are supplied. This prevents area overlap; item groups, units, periods, and source estimates still require separate analytical choices. - **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 This server exposes seven tools (`faostat_dataframe_drop` opt-in via `FAOSTAT_DATAFRAME_DROP_ENABLED`), one skill resource through the MCP Skills extension, and no prompts. Statistical data is reached through the tools; `skills/faostat-guide/SKILL.md` guides comparisons and staged SQL analysis. All tool definitions live in `src/mcp-server/tools/definitions/`. ### Tool Real definitions: `list-domains`, `resolve-codes`, `query-observations`, `commodity-profile`, `dataframe-query`, `dataframe-describe`, `dataframe-drop`. The shape below — typed `errors[]` contract, `ctx.fail()`, `ctx.enrich`, and a `format()` that renders every field — is shared across them. ```ts import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors'; import { getFaostatMirror } from '@/services/faostat-mirror/index.js'; export const resolveCodesTool = tool('faostat_resolve_codes', { title: 'faostat-mcp-server: resolve codes', description: 'Resolve human terms to the opaque codes a query needs …', annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, // enrichment fields reach BOTH client surfaces via ctx.enrich (provenance, // truncation disclosure, empty-result guidance) — separate from output. enrichment: { totalMatches: z.number().describe('Total matches before the result cap.'), truncated: z.boolean().describe('True when matches were capped at the limit.'), notice: z.string().optional().describe('Guidance when nothing matched.'), }, // Typed error contract — ctx.fail(reason, …) is checked against this union at // compile time; the linter enforces conformance; data.reason is auto-populated. errors: [ { reason: 'unknown_domain', code: JsonRpcErrorCode.InvalidParams, when: 'The domain code is not a selected (indexed) FAOSTAT domain.', recovery: 'Call faostat_list_domains to see valid, indexed domain codes.' }, { reason: 'index_not_ready', code: JsonRpcErrorCode.ServiceUnavailable, when: 'Shared dimensions are empty or the selected item/element domain needs a sync.', retryable: true, recovery: 'Run mirror:init or mirror:refresh, or wait for sync, then retry.' }, ], input: z.object({ domain: z.string().min(1).describe('FAOSTAT domain code (e.g. "QCL").'), dimension: z.enum(['area', 'item', 'element']).describe('Which dimension to resolve.'), query: z.string().optional().describe('FTS5 search term ("wheat", "import quantity").'), }), output: z.object({ /* domain, dimension, matches[] with kind: country|aggregate */ }), async handler(input, ctx) { const mirror = getFaostatMirror(); if (!mirror.isSelected(input.domain)) { throw ctx.fail('unknown_domain', `Domain "${input.domain}" is not indexed.`); } if ((input.dimension !== 'area' && !(await mirror.ready(input.domain))) || !(await mirror.dimensions.isPopulated())) { throw ctx.fail('index_not_ready', 'The selected vocabulary requires a completed sync.'); } const { matches, total } = await mirror.resolve(input.domain, input.dimension, { /* … */ }); ctx.enrich({ totalMatches: total, truncated: total > matches.length }); return { domain: input.domain.toUpperCase(), dimension: input.dimension, matches }; }, // 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, the flag included. format: (result) => [{ type: 'text', text: /* render every match + its kind */ }], }); ``` ### Analytical tools — DataCanvas staging `faostat_query_observations` returns one page of up to `limit` rows from `offset` (with `nextOffset` while rows remain, up to the 100,000-row `OFFSET_CAP`) and, with DataCanvas on, a first page whose match exceeds `limit` also **spills** the whole match to a DuckDB-backed canvas table; `faostat_commodity_profile` inlines its summaries and spills its row-level set. Both stage through the helpers in `src/services/canvas-staging.ts`. The returned `canvas_id` + `table_name` are then read through the mandatory `faostat_dataframe_describe` / `faostat_dataframe_query` pair — without that pair the spilled token is dead output. `faostat_dataframe_drop` removes one staged table ahead of its TTL; it is opt-in, so `src/index.ts` registers it through `dataframeDropRegistration()`, which wraps it in `disabledTool()` unless `FAOSTAT_DATAFRAME_DROP_ENABLED=true`, and the server `instructions` name it only when it is live. Guard with `canvasEnabled()`: with staging off, `faostat_query_observations` pages with `offset`, `faostat_commodity_profile` states it in a notice, and the `faostat_dataframe_*` tools surface `canvas_disabled`. The session canvas is acquired through the framework's `acquireShared` (one canvas per tenant, even under concurrent first calls); `faostat_dataframe_describe` and `faostat_dataframe_drop` never mint one. `schema` is required: pass `OBSERVATION_TABLE_SCHEMA` (the `streamObservations` columns, including item text codes, periods, value qualifiers, and sources) or `PROFILE_TABLE_SCHEMA` (those plus `domain`). The staged types are declared, never inferred from the preview rows — inference types `value` from whatever the preview window holds, and a whole-number window truncates every later fractional value. ```ts import { canvasEnabled, OBSERVATION_TABLE_SCHEMA, STAGE_MAX_ROWS, stageObservations, } from '@/services/canvas-staging.js'; // One per call, before its first mirror read: the call's signal plus the 45 s query_timeout ceiling. const signal = mirror.readSignal(ctx.signal); // nextOffset is set by the page read while rows remain past the page. if (input.offset === 0 && nextOffset !== undefined && canvasEnabled()) { const staged = await stageObservations(ctx, mirror.streamObservations(code, filters, STAGE_MAX_ROWS + 1, signal), { sourceTool: 'faostat_query_observations', schema: OBSERVATION_TABLE_SCHEMA, previewLimit: limit.effective, queryParams: { domain: code, /* … */ }, }); // staged.spilled, staged.canvasId, staged.tableName, staged.rowCount } ``` Declare a tool's `canvas_id` **input** field with `CanvasIdSchema` from `@cyanheads/mcp-ts-core/canvas`, plus a local `.describe()` naming the tool that produced the id. The minted `^[A-Za-z0-9_-]{10}$` shape then reaches `inputSchema`, so an impossible value is rejected at argument validation rather than surfacing as a canvas miss. **Output** `canvas_id` fields stay `z.string()` — they echo a value the server minted, not one it validates. ### 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 DEFAULT_DOMAINS = 'QCL,TCL,FBS,FS,RL,GLE,RFN,QV'; const ServerConfigSchema = z.object({ bulkBaseUrl: z.string().url().default('https://bulks-faostat.fao.org/production') .describe('FAOSTAT bulk-download service base URL (manifest + per-domain ZIPs).'), domains: z.string().default(DEFAULT_DOMAINS) .describe('Comma-separated FAOSTAT domain codes to index into the local mirror.'), mirrorPath: z.string().default('./.faostat-mirror') .describe('Directory holding the per-domain SQLite mirrors + shared dimension DB.'), refreshCron: z.string().optional() .describe('Cron for the in-process incremental refresh (HTTP transport only).'), dataframeDropEnabled: z.stringbool().default(false) .describe('Register faostat_dataframe_drop live; otherwise it is registered disabled.'), clampLimits: z.stringbool().default(true) .describe('Serve a count input (limit, top_n, row_limit) above its cap at the cap; false rejects it.'), }); export type ServerConfig = z.infer; /** The environment variable each config field reads. */ const ENV_VARS = { bulkBaseUrl: 'FAOSTAT_BULK_BASE_URL', domains: 'FAOSTAT_DOMAINS', mirrorPath: 'FAOSTAT_MIRROR_PATH', refreshCron: 'FAOSTAT_REFRESH_CRON', dataframeDropEnabled: 'FAOSTAT_DATAFRAME_DROP_ENABLED', clampLimits: 'FAOSTAT_CLAMP_LIMITS', } as const satisfies Record; let _config: ServerConfig | undefined; export function getServerConfig(): ServerConfig { _config ??= parseEnvConfig(ServerConfigSchema, ENV_VARS); return _config; } ``` `src/index.ts` loads `./.env` and, before `createApp()`, reads the drop flag with `dataframeDropRequested()`, the clamp switch with `clampLimitsRequested()`, and the canvas row limit with `canvasRowLimit()` (it caps `faostat_dataframe_query`'s `row_limit`), since each decides how a tool registers. Those reads never throw; the full config is validated by `getServerConfig()` in `setup()`, so a bad value fails behind the framework's startup banner. `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`FAOSTAT_DOMAINS`) not the path (`domains`). Throws `ConfigurationError`, which the framework prints as a clean startup banner. `CANVAS_PROVIDER_TYPE` is a core var (already in `AppConfig`), not part of this schema; `src/index.ts` uses the framework's `normalizeEnv` to treat blank, whitespace-only, and unsubstituted placeholder values as unset before defaulting to `duckdb`. 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 // src/index.ts — name + title are the machine name on every surface (never Title Case). // description is NOT set here — it derives from package.json (the canonical source). await createApp({ name: 'faostat-mcp-server', title: 'faostat-mcp-server', sessionMode: 'stateless', tools: [listDomainsTool, resolveCodesTool, queryObservationsTool, commodityProfileTool, dataframeQueryRegistration(canvasRowLimit()), dataframeDescribeTool, dataframeDropRegistration(dataframeDropEnabled)], instructions: 'Global food & agriculture statistics from the UN FAOSTAT … Workflow: ' + 'faostat_list_domains → faostat_resolve_codes → faostat_query_observations …', setup(core) { /* wire canvas + mirror, schedule HTTP refresh */ }, async teardown() { await refresh?.stop(); await mirror?.close(); }, }); ``` `sessionMode` declares the HTTP session posture in `src/` rather than 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). `stateless` is correct here because no handler calls `ctx.requestInput` — add `require: 'stateful'` only if one ever does, so startup fails loudly instead of serving a mode where a 2025-era client can never answer the prompt. `teardown(core)` is the `setup()` counterpart: it closes the mirror's per-domain SQLite handles and the shared dimension database, which nothing else releases. It runs after the transport stops and before the logger closes, on every shutdown path. It first awaits `refresh?.stop()`, which aborts a scheduled refresh in flight and waits for it to settle — `schedulerService.destroyAll()` runs only after the hook and stops future ticks, not a running one — and a run stopped mid-build stops waiting at once, so shutdown reaches `mirror.close()` well inside the framework's 10 s backstop. `instructions` is optional server-level orientation, sent on every `initialize` as session-level context — here it carries the discover → resolve → query workflow, the aggregate-exclusion default, the paging and canvas staging path, and the data-quality-flag reminder, so the guidance isn't duplicated across every tool description. The canonical in-code identity block is `name` + `title` only — never duplicate `description` into `createApp()` (it derives from `package.json`). --- ## 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). The canvas staging layer resolves the session canvas from here. | | `ctx.enrich` | Attach enrichment fields (declared in `enrichment`) to both client surfaces — `ctx.enrich({ … })`, plus `.total(n)` and `.notice(msg)` shorthands for the common count/guidance fields. | | `ctx.fail` | Throw a typed contract error — `ctx.fail(reason, message, recovery?)`, with `reason` checked against the tool's `errors[]` union. The declared `recovery` reaches the wire on its own; pass `recovery` only to override it. | | `ctx.signal` | `AbortSignal` for cancellation — threaded into manifest/ZIP fetches. | | `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. | `ctx.requestInput` / `ctx.inputs` aren't used — every tool is read-only and non-interactive, which is why `createApp()` declares `sessionMode: 'stateless'` (and `.env.example` plus the Docker image set `MCP_SESSION_MODE=stateless` to match). A handler that ever does need a value the caller didn't supply must `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })` and read `ctx.inputs.accepted(key, schema)` on re-entry — never `await` for user input mid-handler. --- ## 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() — registers tools, wires mirror+canvas in setup() config/ server-config.ts # Server-specific env vars (Zod schema, parseEnvConfig) services/ canvas-accessor.ts # Module-level getCanvas()/setCanvas() (core.canvas, wired in setup()) canvas-staging.ts # spill / query / describe / drop staging over the DataCanvas faostat-mirror/ index.ts # initFaostatMirror() / getFaostatMirror() accessor + barrel faostat-mirror.ts # FaostatMirror — per-domain MirrorService + dimension store read-pool.ts # ReadPool — cube reads + statistics builds on 2 worker threads, per-call query_timeout ceiling read-worker.ts # Worker entry — runs a read or an ANALYZE; self-contained (package + node: imports only) scheduled-refresh.ts # ScheduledRefresh — the HTTP refresh cron's job: per-domain deadline + shutdown stop, skips sync_locked ingester.ts # Streaming bulk-ZIP → SQLite ingester manifest.ts # Bulk manifest fetch/parse (datasets_E.json) csv.ts, dimensions-store.ts, http.ts, types.ts mcp-server/ tools/ markdown-cell.ts # markdownCell() — table-cell escaping shared by the format()s count-input.ts # countInput() / withStrictCountInputs() — limit, top_n, row_limit served at their cap, or rejected when FAOSTAT_CLAMP_LIMITS=false definitions/ list-domains.tool.ts resolve-codes.tool.ts query-observations.tool.ts commodity-profile.tool.ts dataframe-query.tool.ts dataframe-describe.tool.ts dataframe-drop.tool.ts scripts/ faostat-mirror-init.ts faostat-mirror-refresh.ts faostat-mirror-verify.ts _mirror-context.ts ``` No hand-authored `resources/` or `prompts/` definitions. `createApp({ skills: 'skills' })` serves the root `skills/faostat-guide/SKILL.md` through the MCP Skills extension. --- ## Naming | What | Convention | Example | |:-----|:-----------|:--------| | Files | kebab-case with suffix | `query-observations.tool.ts` | | Tool names | snake_case, `faostat_` prefix | `faostat_query_observations` | | Tool `title` | `faostat-mcp-server: ` (machine name, never Title Case) | `faostat-mcp-server: query observations` | | Directories | kebab-case | `src/services/faostat-mirror/` | | Descriptions | Single string or template literal, no `+` concatenation | `'Query a FAOSTAT domain's data cube by area, item, element, year.'` | --- ## Skills Development skills live in `framework-skills/` at the project root. 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. Root `skills/` is separate: it holds author-managed skills for agents installing this server, ships in npm, Docker and MCPB, and reaches MCP clients through `createApp({ skills: 'skills' })`. The maintenance sync never authors that tree; use `add-skill` deliberately. **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-and-publish` | Fast-forward merge (release PR mode) + tag + push + npm + MCP Registry + GH Release + Docker. Picks up from `git-wrapup` | | `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 | | `maintenance` | Investigate changelogs, adopt upstream changes, sync skills to agent dirs | | `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 | | `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 local SQLite + FTS5 mirror of a bulk upstream dataset (`defineMirror` / `sqliteMirrorStore`) — the FAOSTAT data path | | `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-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 + 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 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`) under Bun (`bun:sqlite`), then under real Node (`better-sqlite3`) — the runtime the npm bin and `.mcpb` use. `[run] bun = true` makes `node` inside `bun run` Bun's alias, so the Node lane finds a real `node` on PATH and fails if there is none | | `bun run test:node` | Only the Node lane. Extra arguments go to Vitest (`bun run test:node tests/tools/x.test.ts`) | | `bun run start:stdio` | Production mode (stdio) | | `bun run start:http` | Production mode (HTTP) | | `bun run mirror:init` | One-time bootstrap — download and index the `FAOSTAT_DOMAINS` set into the local mirror. Idempotent, resumable per domain. Required before the data tools answer queries. | | `bun run mirror:refresh` | Re-sync domains whose upstream `DateUpdate` has advanced (skips unchanged), and build query-planner statistics (`ANALYZE`) for any domain whose statistics are missing or do not cover one of its indexes, unchanged ones included. Run out-of-band on stdio; HTTP transport schedules it via `FAOSTAT_REFRESH_CRON`. | | `bun run mirror:verify` | Report per-domain sync status, local row counts, checkpoints, and shared dimension population. | | `bun run lint:mcp` | Validate MCP tool definitions against the spec (format-parity, schema shape, naming). Rule catalog: `api-linter` skill. | | `bun run lint:packaging` | Packaging surface checks — registry/bundle env parity, npm launch shape, bundle exclusions, plugin manifests, and published `skills/` (run by devcheck). | | `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 | **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 `.mcpb` extension bundle for Claude Desktop. Root development paths need root-anchored `.mcpbignore` lines; root `skills/` ships. The cleaner prunes dev dependencies and strips dependency-root agent docs, platform-specific DuckDB bindings, node-gyp generator/intermediate files, and build-only framework/SQLite package files. Nested SDK runtime `dist/ext/skills/` modules and compiled `*.node` addons stay. `manifest.json` disables canvas in the portable bundle; npm and Docker retain DuckDB. MCPB uses stdio. **Adding an env var:** list it in `server.json` (registry discovery, `environmentVariables[]`); add it to `manifest.json` (bundle install UX, `mcp_config.env` + `user_config`) only when the bundle should surface it, since `lint:packaging` (run by `devcheck`) passes with a variable in `server.json` alone. It requires every `user_config` option to be wired into `mcp_config.env` as `"X": "${user_config.X}"` and listed in `server.json`, an optional string option to carry `"default": ""`, and every required `server.json` variable (no default) to be a key in `mcp_config.env`. **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 `bun 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 `bun 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 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 (resolves to ./src/*; ./dist/* in the Docker image) import { getFaostatMirror } from '@/services/faostat-mirror/index.js'; import { canvasEnabled, stageObservations } from '@/services/canvas-staging.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 - [ ] If wrapping external API: raw/domain/output schemas reviewed against real upstream sparsity/nullability before finalizing required vs optional fields - [ ] If wrapping external API: normalization and `format()` preserve uncertainty; do not fabricate facts from missing upstream data - [ ] If wrapping external API: tests include at least one sparse payload case with omitted upstream fields - [ ] FAOSTAT observations: the data-quality `flag` is carried through `output` and `format()` — never dropped - [ ] `faostat_query_observations` keeps area aggregate exclusion as the default (threshold plus curated rollups; explicit area selections bypass it); item-group classification does not change query inclusion - [ ] Query-path tools tested against a built mirror (`bun run mirror:init`) — the read tools surface `index_not_ready` until the first sync completes - [ ] Registered in the `createApp()` `tools` array 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` = package name; `interface.shortDescription` from `package.json` description - [ ] `.codex-plugin/mcp.json` server name key matches `package.json` name (no API keys — FAOSTAT is keyless) - [ ] `.claude-plugin/plugin.json` populated — `name`, `version`, `description`, `repository`, `license` from `package.json`; inline `mcpServers` entry with the server name key - [ ] Root `plugin.json` and `mcp.json` follow Agent Plugins 1.0.0; unscoped display name, full npm install name, fixed env matching `.codex-plugin/mcp.json`; keep the Codex overlay's `mcpServers` pointer - [ ] 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