# Developer Protocol **Server:** pokeapi-mcp-server **Version:** 0.3.1 **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.22` **Engines:** Bun ≥1.4.4, Node ≥24.0.0 **MCP SDK:** `@modelcontextprotocol/server` ^2.4.0 **Zod:** ^4.6.7 > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only. --- ## 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, not to log or return an error envelope. Plain `Error` is fine; the framework catches, classifies, and formats. Catch a typed upstream error only when mapping it to a declared `errors[]` contract with `ctx.fail(...)`. - **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 ```ts import { tool, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors'; import { getPokeApiService } from '@/services/pokeapi/pokeapi-service.js'; export const getNature = tool('pokeapi_get_nature', { description: 'Nature details by name or ID — stat boost and penalty, preferred and disliked berry flavor. Returns all 25 natures when called without an identifier.', annotations: { readOnlyHint: true, openWorldHint: false }, input: z.object({ identifier: z.string().optional().describe('Nature name (e.g. "modest") or ID 1–25. Omit to list all 25 natures.'), }), output: z.object({ natures: z.array(z.object({ id: z.number().describe('Nature ID.'), name: z.string().describe('Nature name.'), increasedStat: z.string().nullable().describe('Stat boosted by +10%. Null for neutral natures.'), decreasedStat: z.string().nullable().describe('Stat reduced by −10%. Null for neutral natures.'), likesFlavor: z.string().nullable().describe('Preferred berry flavor. Null for neutral natures.'), hatesFlavor: z.string().nullable().describe('Disliked berry flavor. Null for neutral natures.'), })).describe('Nature entries.'), isListAll: z.boolean().describe('True when all 25 natures are returned.'), }), errors: [ { reason: 'not_found', code: JsonRpcErrorCode.NotFound, when: 'Identifier resolves to no nature.', recovery: 'Use a valid nature name (e.g. "modest") or an ID from 1–25.' }, ], async handler(input, ctx) { const svc = getPokeApiService(); if (!input.identifier) { const natures = await svc.getAllNatureDetails(ctx); ctx.log.info('Listed all natures', { count: natures.length }); return { natures, isListAll: true }; } try { const nature = await svc.getNatureDetails(input.identifier, ctx); return { natures: [nature], isListAll: false }; } catch (err) { if (err instanceof McpError && err.code === JsonRpcErrorCode.NotFound) { throw ctx.fail('not_found', `Nature '${input.identifier}' not found`); } throw err; } }, format: (result) => [{ type: 'text', text: result.natures.map(n => `**${n.name}** (ID ${n.id}): +${n.increasedStat ?? '—'} / −${n.decreasedStat ?? '—'}` ).join('\n'), }], }); ``` Tool inputs are strict at the root by default: `tools/list` advertises `additionalProperties: false`. Before validation, the framework drops undeclared client metadata keys and accepts unambiguous case-style key aliases; remaining unknown keys are rejected by name. Argument rejections carry `InvalidParams` with a reason and recovery hint. The advertised output schema accepts either the success shape or the framework's structured error envelope; the handler must satisfy the declared `output` schema. `error` is reserved in `output` and `enrichment` schemas. Handlers return the output schema's input type. The framework parses it once, and `format()` receives the parsed result. Enrichment is parsed independently and its keys must be disjoint from the output keys. ### Resource ```ts import { resource, z } from '@cyanheads/mcp-ts-core'; import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors'; import { getPokeApiService } from '@/services/pokeapi/pokeapi-service.js'; export const pokemonResource = resource('pokeapi://pokemon/{identifier}', { description: 'Pokémon dossier by name or PokéAPI Pokémon-record ID — same payload as pokeapi_get_pokemon without moves.', params: z.object({ identifier: z.string().describe('Pokémon name or PokéAPI Pokémon-record ID.') }), errors: [{ reason: 'not_found', code: JsonRpcErrorCode.NotFound, when: 'No Pokémon matches the identifier.', recovery: 'Use a valid Pokémon name or numeric PokéAPI Pokémon-record ID.', }], async handler(params, ctx) { const svc = getPokeApiService(); try { return await svc.getPokemonDossier(params.identifier, false, undefined, ctx); } catch (err) { if (err instanceof McpError && err.code === JsonRpcErrorCode.NotFound) { throw ctx.fail('not_found', `Pokémon '${params.identifier}' not found`); } throw err; } }, }); ``` ### 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({ baseUrl: z.string().default('https://pokeapi.co/api/v2').describe('PokéAPI base URL'), cacheTtlSeconds: z.coerce.number().default(21600).describe('Cache TTL in seconds'), requestTimeoutMs: z.coerce.number().default(10000).describe('Per-request timeout in ms'), }); let _config: z.infer | undefined; export function getServerConfig() { _config ??= parseEnvConfig(ServerConfigSchema, { baseUrl: 'POKEAPI_BASE_URL', cacheTtlSeconds: 'POKEAPI_CACHE_TTL_SECONDS', requestTimeoutMs: 'POKEAPI_REQUEST_TIMEOUT_MS', }); return _config; } ``` `parseEnvConfig` maps Zod schema paths → env var names so errors name the variable (`MY_API_KEY`) not the path (`apiKey`). Throws `ConfigurationError`, which the framework prints as a clean startup banner. For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables. ### Server identity and instructions `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`): ```ts await createApp({ name: 'pokeapi-mcp-server', title: 'pokeapi-mcp-server', instructions: 'Use pokeapi_get_pokemon for a complete Pokémon profile in one call.', }); ``` `name` and `title` must equal the unscoped package name (`pokeapi-mcp-server`); `lint:packaging` (check 9) enforces this. `instructions` is optional server-level orientation, sent on every `initialize` as session-level context. --- ## Context The app declares `sessionMode: 'stateless'`. A meaningful `MCP_SESSION_MODE` overrides it; empty and unsubstituted placeholder values fall through to the app default. The framework schema default `auto` resolves to stateful. Tenant-scoped caching is independent of sessions. If a future tool needs `ctx.requestInput`, declare `{ default: 'stateful', require: 'stateful' }`. Release service watchers, sockets, and timers in `createApp({ teardown })` when a service allocates them. 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 client-visible `notifications/message`. | | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Values round-trip as JSON; reads return fresh objects and a `Date` becomes an ISO string. Used here for PokéAPI response caching. | | `ctx.requestInput` | Suspend and ask the caller for missing input. Return it directly; the handler is re-entered with responses. Always present. | | `ctx.inputs` | Client-supplied responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — filtered to the client's declared capabilities. Consent gates redeem a stored `ctx.state` record bound to the operation, caller, and target; see `api-context`. | | `ctx.clientCapabilities` | What the client declared for this request, or `undefined`. Use to decide whether to ask for optional context, never to skip consent. | | `ctx.enrich` | Success-path context such as query echo, notices, and totals. Reaches both client response surfaces when the definition declares `enrichment`. | | `ctx.content` | Add non-text image or audio content blocks to `content[]`. | | `ctx.signal` | `AbortSignal` for cancellation. | | `ctx.requestId` | Request ID carried by the call's logs and error envelope as `data.requestId`. | | `ctx.tenantId` | Tenant ID from JWT or `'default'` for stdio and unauthenticated HTTP. | --- ## 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 checks reason names; the framework fills `data.recovery.hint` from the declared reason when the thrown error has no recovery of its own, and mirrors the hint into `content[]`. Use an explicit `{ recovery: { hint: '...' } }` only for runtime-specific guidance. `recovery` is required (≥ 5 words, lint-validated). Error envelopes also carry `data.requestId`; tool text ends with the same ID. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) need no declaration. Output-contract failures are `InternalError`, not caller input errors. ```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.DatabaseError, '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. Mark a reason produced below the handler with `thrownBy: 'service'` so the unthrown-contract check recognizes it. Tool contracts can set `severity` to `debug`, `info`, `notice`, or `warning` without changing the wire error. `runToolContract` applies the same recovery fill as production; a direct handler invocation sees only the authored throw data. --- ## Structure ```text src/ index.ts # createApp() entry point config/ server-config.ts # Server-specific env vars (Zod schema) services/ pokeapi/ pokeapi-service.ts # PokeApiService — typed fetch methods, cache, retry types.ts # Domain types mcp-server/ prompts/definitions/ team-builder.prompt.ts # pokeapi_team_builder (pure client workflow) tools/definitions/ find-pokemon.tool.ts # pokeapi_find_pokemon get-ability.tool.ts # pokeapi_get_ability get-item.tool.ts # pokeapi_get_item get-move.tool.ts # pokeapi_get_move get-nature.tool.ts # pokeapi_get_nature get-pokemon.tool.ts # pokeapi_get_pokemon (flagship) get-type-matchups.tool.ts # pokeapi_get_type_matchups resources/definitions/ pokemon.resource.ts # pokeapi://pokemon/{identifier} type.resource.ts # pokeapi://type/{typeName} ``` --- ## 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 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. A published skill in root `skills/` reaches plugin installs and the `.mcpb` bundle as-is, ships in the npm package and the Docker image, and reaches MCP clients when the server opts in with `createApp({ skills: 'skills' })`. `lint:packaging` validates the tree. **Agent skill directory:** Copy `framework-skills/` into the directory your agent discovers (Claude Code: `.claude/skills/`, others: equivalent). Skills then load as context without referencing `framework-skills/` paths. After framework updates, run the `maintenance` skill — Phase B re-syncs the agent directory. Available skills: | Skill | Purpose | |:------|:--------| | `setup` | Post-init project orientation | | `design-mcp-server` | Design tool surface, resources, and services for a new server | | `add-tool` | Scaffold a new tool definition | | `add-app-tool` | Scaffold an MCP App tool + paired UI resource | | `add-resource` | Scaffold a new resource definition | | `add-prompt` | Scaffold a new prompt definition | | `add-service` | Scaffold a new service integration | | `add-test` | Scaffold test file for a tool, resource, or service | | `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 | | `field-test` | Exercise tools/resources/prompts with real inputs, verify behavior, report issues | | `techniques` | Catalog of reusable response/data-shaping patterns — outline-on-overflow, truncation disclosure, stateless re-call contract | | `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 the work as a commit stack, bump the version, and open the release PR | | `release-pr-review` | Review an open release PR; fixes are ordinary commits on its branch | | `release-and-publish` | Fast-forward main, tag, push, and publish npm, MCP Registry, GH Release, and Docker | | `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-canvas` | DataCanvas: register tabular data, run SQL, export, plus the `spillover()` helper for big result sets — Tier 3 opt-in | | `api-config` | AppConfig, parseConfig, env vars | | `api-context` | Context interface, RequestContext, logger, state, multi-round-trip input | | `api-errors` | McpError, JsonRpcErrorCode, error patterns | | `api-linter` | Definition linter rule catalog — invoked by `bun run lint:mcp` and `devcheck` | | `api-mirror` | MirrorService: persistent self-refreshing local mirror (embedded SQLite + FTS5) of a bulk upstream dataset — Tier 3 opt-in | | `api-services` | LLM, Speech, Graph services | | `api-testing` | createMockContext, test patterns | | `api-utils` | Formatting, parsing, security, pagination, scheduling, telemetry helpers | | `api-telemetry` | OTel catalog: spans, metrics, completion logs, env config, cardinality rules | | `api-workers` | Cloudflare Workers runtime | **Chaining skills into pipelines.** When the user wants a multi-phase effort — build this server out, QA-and-fix the surface, update-and-ship — *and you can spawn sub-agents*, `framework-skills/orchestrations/SKILL.md` sequences the task skills above into a gated pipeline with verification at each step. Read it to drive the run. Optional: skip it if you can't orchestrate sub-agents, and ignore it entirely if you were *spawned* as one — you've already been scoped to a single phase. When you complete a skill's checklist, check the boxes and add a completion timestamp at the end (e.g., `Completed: 2026-03-11`). --- ## Commands **Runtime:** Bun executes TypeScript natively. Use `bun run `; `npm run ` delegates to Bun for compatibility. | 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 lint:mcp` | Verify MCP definitions, schemas, and typed error contracts | | `bun run lint:packaging` | Verify package, manifest, server, plugin, and bundle metadata | | `bun run audit:fix` | Upgrade vulnerable packages to the lowest safe version within existing ranges | | `bun run audit:refresh` | Delete `bun.lock` and reinstall; last resort after `audit:fix`, `bun update `, and `bun dedupe`, since every ranged dependency re-resolves | | `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 the Vitest suite | | `bun run list-skills` | List the project skill registry | | `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 publish-mcp` | Publish to MCP Registry via mcp-publisher | --- ## Bundling **CI is one file.** `.github/workflows/codeql.yml` is the only GitHub Actions workflow, with repository CodeQL default setup off. Verification and release gates run locally; do not add a workflow that repeats them. `bun run bundle` produces a stdio `.mcpb` extension bundle for one-click install in Claude Desktop. `mcpb pack` reads root-anchored `.mcpbignore` patterns, and the server's root `skills/` ships. `scripts/clean-mcpb.ts` prunes dev dependencies, platform-specific bindings, build-only files and dependency agent docs. Dependency agent directories and protocol files are stripped only at package roots, while `SKILL.md` is stripped anywhere inside dependencies. Nested runtime modules such as the SDK's `dist/ext/skills/` stay in the bundle. **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` verifies name parity, that each `user_config` option is referenced as `${user_config.