MCP in Production · Chapter 11
The first MCP server I shipped was a glorified curl wrapper. I wrote it in an afternoon, registered six tools, called connect() to a stdio transport, and watched Claude list every device on my Tailnet through it. I was thrilled. I deployed it that evening. By the end of the week I had deleted half the code, rewritten the auth model, and learned that "the LLM should be able to do anything a human user can do" is a slogan, not an architecture.
That server was @yawlabs/tailscale-mcp. It is the oldest of the fourteen MCP servers I now run at Yaw Labs, and the one that taught me the most because it is the one where I made every mistake first. Three of the others -- npmjs-mcp, aws-mcp, lemonsqueezy-mcp -- inherited those lessons but acquired their own scars. This chapter walks through all four start to finish: what they do, how they are wired, what I got wrong, and what they look like in production today.
The chapters before this one were mostly about patterns. This chapter is mostly about specifics. If you want to know what a real, day-job MCP server looks like at version 1.x -- the file layout, the tool registration code, the auth plumbing, the bug I shipped at 11pm and the one I caught the next morning -- this is the one to read with the source open.
A note on the code excerpts. The snippets in this chapter are taken from the shipped servers, edited for line length and stripped of the prefixes the production tools carry. Production tool names carry per-server prefixes (tailscale_*, npm_*, aws_*, ls_*); I drop them in displayed examples to keep the chapter focused on shape rather than the prefix-vs-no-prefix argument that Ch5 takes a position on. The prefix strip applies inside descriptions too, where one tool's description names a sibling tool -- so a sibling reference that reads (versions) in the excerpt is (npm_versions) in the production source, and call in an aws-mcp excerpt is aws_call in production. The displayed code stays internally coherent; a model running these snippets as-shown would resolve the sibling references to the bare registered names. Where I have edited a handler, it is to remove logging, rate-limit accounting, or distracting error envelopes that obscure the load-bearing pattern -- the validation, the upstream call, the cancellation plumbing, the response shape. If you want the unedited handlers, read the source on github.com/YawLabs; the file paths in each excerpt point to the real file. If a function looks suspiciously short it is because the production version is suspiciously short -- I have a strong preference for tool handlers that fit on one screen. Most are around 30 lines. If your handler is over 100 lines you almost certainly have two tools mashed together and should split them.
I ran a Tailnet for my home lab and a separate one for Yaw Labs production. I had maybe forty devices across the two and a habit of leaving expired auth keys lying around because rotating them required logging into the admin console, finding the right tab, and clicking through three confirmations per key. The console is fine. It is not fine when you are doing it for the eighth time in a month.
The first version of the server was self-serving in the most literal sense: I wanted to say "Claude, expire any preauth key on the yaw.sh tailnet older than 30 days" and have it work. Tailscale ships a perfectly good REST API. The MCP layer was a thin shim so that the LLM could see the device list as structured data instead of having to parse the HTML I would have copy-pasted into the chat.
It was also a forcing function for me to learn the SDK. I had read the spec but not built anything substantial against @modelcontextprotocol/sdk. Building tailscale-mcp meant I had to confront every part of the surface: tool schemas, transports, error envelopes, lifecycle. Most of what I now know about how to design MCP servers I learned by building this one wrong and then rebuilding it right.
The tool surface has grown over time. I will not list every tool, only a representative slice across the entity domains the server organizes itself around:
Devices:
list_devices -- returns devices on a tailnet, with server-side filters by any top-level device property.get_device -- single-device detail by node ID.delete_device -- permanently remove a device from the tailnet.authorize_device -- approve a device that is pending authorization.Keys:
list_keys -- list active auth keys with expiry and reusability flags.get_key -- single-key detail.create_key -- mint a new auth key with the requested capabilities.delete_key -- revoke an auth key by ID.ACL:
get_acl -- fetch the current ACL document.update_acl -- replace the ACL document, with a dry-run option that returns the diff and the policy validator's response without committing.Other:
list_dns_nameservers -- read the tailnet's DNS configuration.list_audit_log -- query the tailnet's admin audit log.That is a representative slice; the full surface is around 25 tools across 13 source files (devices, keys, ACL, DNS, audit, invites, log streaming, posture, services, status, tailnet, users, webhooks) in the production server. There used to be a much smaller set, but the surface grew as I and a couple of beta users hit Tailscale operations the dashboard made painful. Each addition is a line item in the model's tool-selection problem, which is a tradeoff I am increasingly nervous about and will revisit in a future major.
Auth is a Tailscale OAuth client. I considered three options and discarded two:
I went with option 3. The server reads TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET from env, mints a short-lived bearer token at startup, refreshes it before expiry, and uses it for every call.
Transport is stdio. Hosting is Yaw MCP (mostly because I run it; if you are not me, run it locally or in your own container). Deployment is npm publish -> docker pull -> systemd unit; nothing exotic.
I made a lot of mistakes in tailscale-mcp. The chapter would be too short if I did not list them, and they all became rules I carry into every server I have built since.
Mistake 1: Resources for everything, then deleted them.
In v0.1 I exposed every Tailscale entity as an MCP resource: tailscale://acl/current, tailscale://devices/current, tailscale://users/current, tailscale://dns/current. The thinking was that resources are the "read" surface and tools are the "write" surface, so anything readable should be a resource. This is technically defensible and practically useless.
The problem is that Claude does not browse resources opportunistically the way a human might browse a filesystem. Resources are useful when there is a stable URI the model can be told about and pull on demand -- a configuration file the user has handed it, for example. For a queryable backend like Tailscale, "list and filter devices" is a tool call, not a resource fetch. Resources added a parallel API surface that nobody used and that I had to keep in sync with the tools.
I deleted every resource in v0.3. Nobody noticed. Nobody has asked since.
From the field: Resources earn their keep when the URI is the unit of value -- a fixed thing you can hand the model a pointer to. For "give me a filtered slice of a remote dataset," tools are correct. Don't ship a parallel resource API to feel API-shaped. Ship the surface the model will actually use.
Mistake 2: Cancellation that didn't actually cancel.
The MCP spec has a cancellation notification. The SDK plumbs it through. I handled it in list_devices by setting an aborted flag and returning early. This works fine when "early" means "before the next iteration of a loop." It does not work when the slow part is a network call to Tailscale, because the network call is not watching the flag.
I noticed when a user (me) hit Ctrl-C on a long ACL diff and the server kept hammering Tailscale's API for another four seconds before printing the result we no longer wanted. The fix was to plumb an AbortSignal through every fetch and pass it to the underlying HTTP client. Now cancellation is real cancellation; the in-flight request gets aborted, the connection closes, and the handler returns within milliseconds.
Mistake 3: ACL parsing that broke on the HuJSON the API actually returns.
Tailscale ACLs are HuJSON -- JSON with comments and trailing commas -- and the Admin API faithfully returns whatever comments and trailing commas the operator put in. My first parser was JSON.parse, on the assumption that "JSON-ish" was close enough. It was not. Real-world ACLs have line comments above every section ("// admins can reach everything in prod"), block comments around deprecated rules someone left in for context, and trailing commas after the last entry of nearly every array. JSON.parse choked on the first comment.
I caught this because Claude told a user that an ACL fetch had failed when in fact the fetch had succeeded and only the parse had failed. The fix was to swap in a HuJSON-aware parser (the hujson package strips comments to canonical JSON before parsing, which I use to this day) and to keep the original byte stream around so update_acl can show a diff in the operator's own formatting rather than a re-serialized canonical form. The lesson was: when wrapping a permissive backend, exercise the corner cases the backend permits, not just the canonical input you write yourself.
tailscale-mcp is a small TypeScript codebase organized by Tailscale entity (devices, keys, ACLs, DNS, audit, posture, services, status, users, webhooks, and a few more), with one file per entity in src/tools/ exporting an array of tool definitions. The auth layer lives in its own module and is the only thing in the codebase that knows about OAuth. The transport bootstrap is a short src/index.ts that connects a StdioServerTransport and is otherwise unremarkable.
If I were starting again today I would skip the resources phase entirely, ship cancellation correctly the first time, and use an HTTP client that defaults AbortSignal propagation (we use undici now, with an AbortController per request).
// src/tools/delete_device.ts
import { z } from 'zod';
import type { ToolDefinition, ToolHandler } from '../types.js';
import { tailscaleFetch } from '../auth/oauth.js';
export const definition: ToolDefinition = {
name: 'delete_device',
description: [
'Permanently remove a device from the tailnet. The device must',
're-authenticate to rejoin. Irreversible; for a softer action that',
'forces re-auth without removing the device record, use expire_device.',
].join(' '),
inputSchema: {
device_id: z.string().describe('The device ID, as returned by list_devices (numeric id or nodeId, NOT the nodeKey).'),
},
annotations: {
destructiveHint: true,
idempotentHint: true, // delete twice is still deleted
openWorldHint: true, // hits the Tailscale Admin API
},
};
export const handler: ToolHandler = async (args, ctx) => {
const parsed = z.object({
device_id: z.string().min(1),
}).parse(args);
const res = await tailscaleFetch(
`/device/${encodeURIComponent(parsed.device_id)}`,
{ method: 'DELETE', signal: ctx.signal },
);
if (res.status === 404) {
return { content: [{ type: 'text', text: `Device ${parsed.device_id} not found; nothing to delete.` }] };
}
if (!res.ok) {
const detail = await res.text();
return {
isError: true,
content: [{
type: 'text',
text:
`Tailscale API returned ${res.status} deleting device ${parsed.device_id}: ${detail}. ` +
`If this is a 5xx, retry once. If it is a 4xx, the device may be in a state the API cannot remove directly; ` +
`call list_devices to confirm it is still present before retrying.`,
}],
};
}
return { content: [{ type: 'text', text: `Deleted device ${parsed.device_id}.` }] };
};
The interesting bits are the destructiveHint annotation, the signal propagation into tailscaleFetch, and the explicit 404 branch -- I want the model to see "not found" as a normal completion, not as an error to retry. Errors are for things the caller did not intend; "device was already gone" is a meaningful answer.
The CLAUDE.md for every Yaw Labs repo describes a publish flow that goes like this. To publish, log in interactively with npm login --auth-type=web so a WebAuthn session lands in ~/.npmrc. Then npm publish --access public. Sometimes the first publish after a fresh login fails with EOTP because npm's auth backend has not propagated; retry two or three times with a thirty-second wait.
That flow works. I have run it many times. It is also incompatible with two things I want: I want my agent to be able to deprecate a package without me sitting at a terminal, and I want CI to publish with an automation token rather than a session token, because session tokens fail in headless CI with misleading errors.
The CLI is the wrong substrate for both. The CLI's auth model is interactive-first; the npm registry's HTTP API is not. There is a documented REST surface for everything the CLI does (publish, deprecate, dist-tag, owner), and that surface accepts an automation token in a plain Authorization header. So I wrote a server that talks the HTTP surface directly and exposes the write operations as MCP tools.
The win is not just CI. The win is that "deprecate the rename source after a rename" becomes a one-line tool call instead of a CLI dance, and the registry's failure modes (404 on a wrong scope, 422 on a range that matches no versions, 401 on a session-bound token) come back as actionable text the model can route on, not as opaque numbers I have to remember to grep for.
npmjs-mcp ended up larger than I expected. The seed was the write surface -- the operations that fight the CLI -- but once I had the HTTP layer wrapped I kept finding read endpoints that were also more useful through MCP than through npm view | jq. The current surface is around 60 tools across fifteen files in src/tools/, organized roughly: package metadata (package, version, versions, readme, dist_tags, types), search, downloads, security (audit, audit_deep, signing_keys), dependency analysis (dependencies, dep_tree, license_check), comparison and health (compare, health, release_frequency), org and team browsing (org_members, org_packages, team_packages), provenance and trusted publishers, hooks, registry ops (registry_stats, recent_changes, ops_playbook), and the writes.
The writes are the part I built first and the part I still care about most:
deprecate -- mark a package (or a semver range of versions) as deprecated, with a message.undeprecate -- clear the deprecation flag.dist_tag_set -- attach a dist-tag to a version (latest, next, beta, etc.).dist_tag_remove -- remove a dist-tag.unpublish_version and unpublish_package -- the irreversible ones, gated behind confirm: true.owner_add, owner_remove -- maintainer management.access_set, access_set_mfa -- per-package access and 2FA policy.team_create, team_delete, team_grant, team_revoke, team_member_add, team_member_remove -- the team and grants surface.org_member_set, org_member_remove, token_revoke -- org-level housekeeping.publish is conspicuously missing. I do not expose publish as a tool because publish is the one operation where I want a human-readable artifact (the tarball) to land in a registry only after a human (or CI, with strict guardrails) has explicitly asked. Publishing from an LLM tool call is one fat-finger away from shipping the wrong version of the wrong package, and the unpublish escape hatch closes fast (npm refuses to unpublish a version older than 72 hours). I ship publish through the CI release workflow on a v* tag push and nowhere else.
Auth is a single NPM_TOKEN env var, set to an automation token (the kind that starts npm_ and does not require WebAuthn). The token is a Yaw Labs org-level secret in CI and lives in my local env for development.
The HTTP layer is fetch against https://registry.npmjs.org (plus https://api.npmjs.org for downloads and https://replicate.npmjs.com for the changes feed). There is no SDK; the npm registry's HTTP surface is documented and stable enough to wrap directly. The HTTP layer carries the auth header injection, retry-with-backoff for 429/5xx, request-timeout handling, and a set of identifier validators (package name, scope, dist-tag, team, username -- all regex-checked against npm's actual constraints) so that malformed input fails at the tool boundary with a useful message instead of returning an opaque 404 from the registry.
Transport is stdio. Hosting follows the same pattern as tailscale-mcp.
Mistake 1: I baked an unwritten format rule into the tool, then took it back out.
A 422 on a deprecation message sent me down a wrong path. My first attempt went "Renamed to @yawlabs/newpkg. Install that instead." and 422'd. My second attempt went "Renamed to @yawlabs/newpkg -- install that instead" (em-dash, lowercase after the dash, no trailing period) and succeeded. I had two data points and I drew a confident line between them: npm has an unwritten format check; encode it in the wrapper. I added a validateDeprecationMessage that flagged the period-space-capital pattern and rewrote it to the em-dash form before sending.
That validator was wrong. A user filed an issue saying their canonical-English deprecation message was being rejected on a package where the supposedly-bad format had worked fine the day before. I dug in, and the original 422 turned out to be a wildcard-version issue, not a message-format issue at all -- versionRange: "*" was matching no published versions on a fresh package and the registry was 422'ing on the empty match. The pattern check was producing false positives every time anyone wrote a normal sentence into a deprecation message. I deleted it in v0.10. The validator now enforces only the one rule the registry actually documents: messages must be at most 1024 characters.
Mistake: I drew a load-bearing rule from two data points. The "format gotcha" was real for the first failure (which had a different cause) and not real at all for the second; I had pattern-matched two coincidences into a wrapper-side normalizer. The lesson, in retrospect: when wrapping a backend with opaque error responses, before you encode a rule in the wrapper, get a third data point that isolates the variable. A check that 422s on shape can also 422 on every other thing the request gets wrong, and "I changed three things and it worked" is not a controlled experiment.
Mistake 2: Windows ConPTY mojibake on terminal output.
The first version of the server printed status lines to stderr when run from a terminal, with em-dashes and bullet points. On macOS and Linux this looked fine. On Windows ConPTY -- which is what most of my testers were running -- the output came back as ΓÇö and ΓÇó because of a codepage race between Node's UTF-8 stderr writer and the console's active codepage at render time.
I had two options: configure the console codepage on startup (fragile, requires shell-specific incantations, can fail silently on PowerShell vs cmd) or just emit ASCII. I picked ASCII. The status output uses -- for em-dash, * for bullet, >= for ≥, straight quotes for curly quotes, and so on. The chapter's prose can use whatever Markdown renders correctly; the terminal output cannot.
This is a small thing that compounded badly. Users who saw mojibake assumed the server was broken and reported it as such. The fix was a fifteen-minute rewrite of about forty status strings; the lesson was that anything which flows through a Windows terminal at the system level should be ASCII unless you have a specific reason and a tested codepath.
Mistake 3: I tried to support ~/.npmrc session tokens.
For about three days in v0.2 I had a code path that read ~/.npmrc and pulled the token from there if NPM_TOKEN was unset. This was a convenience for "I just logged in via the CLI, why do I need to set an env var?" The convenience was a trap. Session tokens from npm login --auth-type=web are 2FA-bound and fail in some contexts (notably, headless CI, but also npm publish retries shortly after login). Having the server transparently fall back to a token that might or might not work made debugging awful.
I deleted the fallback in v0.3 and made NPM_TOKEN required, with a clear error message: "Set NPM_TOKEN to an automation token (starts with npm_). Session tokens from ~/.npmrc are not supported; use a token from https://www.npmjs.com/settings/
npmjs-mcp organizes those tools into one file per tool family in src/tools/: writes.ts, packages.ts, orgs.ts, security.ts, dependencies.ts, analysis.ts, downloads.ts, hooks.ts, access.ts, auth.ts, provenance.ts, trust.ts, registry.ts, search.ts, workflows.ts. The HTTP layer is src/api.ts, the error-translator (which turns 401/403/404/422/429 into messages a model can act on) is src/errors.ts, and the bootstrap is src/index.ts. There is no shared "tools framework" -- each tool is a literal object with name, description, annotations, inputSchema, and handler, and the index just spreads the per-family arrays into one big list. Anything fancier would have outpaced what the surface actually needed.
The real npm_deprecate is a packument-mutation flow, not a POST /deprecations call -- the npm registry's deprecation surface is "GET the full packument, mutate the deprecated field on each affected version, PUT it back." That shape carries the explanation of the v0.10 mistake: validation was layered on top of a flow that already had its own ways to fail.
// src/tools/writes.ts (excerpt)
{
name: 'deprecate',
description:
'Deprecate a package or specific versions. Shows a warning message on install. ' +
'Uses the HTTP API with NPM_TOKEN, bypassing the interactive WebAuthn/OTP friction of the npm CLI. ' +
'Registry hard limit: deprecation messages must be <= 1024 characters. ' +
'If the registry 422s, first verify the semver range matches at least one published version ' +
'(versions) -- range/version mismatches are the most common cause, not message format.',
annotations: {
destructiveHint: true,
idempotentHint: true,
openWorldHint: true,
},
inputSchema: z.object({
name: z.string().describe("Package name, e.g. '@yawlabs/spend'"),
message: z.string().describe(
'Deprecation message. Empty string to clear (use undeprecate instead).',
),
versionRange: z.string().optional().describe(
"Semver range. Omit to deprecate ALL versions. Example: '<1.0.0' or '0.3.x'.",
),
}),
handler: async (input) => {
const authErr = requireAuth();
if (authErr) return authErr;
const problem = validateDeprecationMessage(input.message);
if (problem) return { ok: false, status: 400, error: problem };
// 1. GET /{pkg}?write=true to get the full packument with _rev
const pRes = await fetchPackument(input.name);
if (!pRes.ok) return translateError(pRes, { pkg: input.name, op: 'deprecate (fetch)' });
const packument = pRes.data;
const allVersions = Object.keys(packument.versions || {});
const range = input.versionRange ?? '*';
const affected = versionsMatchingRange(allVersions, range, maxSatisfying);
if (affected.length === 0) {
return {
ok: false,
status: 400,
error:
`No versions match range '${range}' for ${input.name}. ` +
`Published versions: ${allVersions.join(', ') || '(none)'}.`,
};
}
// 2. Mutate: set the deprecated field on each affected version
for (const v of affected) {
packument.versions[v].deprecated = input.message;
}
// 3. PUT the whole packument back
const putRes = await registryPutAuth(`/${encPkg(input.name)}`, packument);
if (!putRes.ok) return translateError(putRes, { pkg: input.name, op: 'deprecate (write)' });
return {
ok: true,
status: 200,
data: {
package: input.name,
affectedVersions: affected,
totalAffected: affected.length,
message: input.message,
},
};
},
},
Three things in the description are doing the work. It explains the operation. It names the only documented registry constraint (the 1024-char limit). And -- because of the v0.10 lesson -- it tells the model that a 422 most likely means the range matched no versions, with a concrete next step (versions) before retrying. That last sentence is the lesson encoded into the tool surface: when the wrapper used to silently rewrite messages, the model never saw the real failure mode; now it does, and it can pick the right next call.
The error-translation layer matters too. translateError (in src/errors.ts) turns the registry's bare 401/403/404/422/429 into messages that name the most likely cause, the next tool to call, and the CLI fallback when the issue is account-level 2FA. The model doesn't have to guess. We will see in the next section how much of a difference signal like this makes when the tool surface gets larger.
The other three servers in this chapter wrap a single backend with a small, stable surface. aws-mcp wraps AWS, which is several dozen backends with a sprawling, evolving surface, and the design challenge is not "wrap the API" but "decide which fraction of the API to expose and how to organize it."
I built it because I was managing three small AWS accounts (Yaw Labs prod, Yaw Labs staging, my personal projects) and the AWS console is a maze and the AWS CLI is a maze with autocomplete. "Show me the running EC2 instances in us-east-1 across all three accounts" is two minutes of tab-switching in the console, three commands and a jq pipeline in the CLI, or a couple of MCP tool calls (one for each account profile) that the model orchestrates and stitches in seconds.
This is the most complex of the four servers in this chapter. It is also the one where I made the most consequential design mistakes, because at this scale of API surface the design mistakes have nowhere to hide.
The current tool surface is eighteen tools and is deliberately generic. There is no per-service split (no ec2_*, no s3_*, no iam_*). The bet -- and it took me three rewrites to get to this bet -- is that AWS has too many services for a per-service taxonomy to hold up, and the right factoring is "operations on resources" + "call any AWS API directly when you need something the resource layer cannot do" + "auth and session management."
Generic resource CRUD via Cloud Control API:
resource_get -- read a single resource by typeName (a CloudFormation type name like AWS::Lambda::Function) plus identifier.resource_list -- paginated list of resources of a given type, with cursor-based continuation.resource_create, resource_update, resource_delete -- async by default, returning a requestToken; pass awaitCompletion: true and the server polls to terminal state for you.resource_status -- poll a previous async request by token.Generic AWS API access for anything CCAPI doesn't cover:
call -- run any AWS CLI operation. service: 's3api', operation: 'list-buckets', optional params (PascalCase JSON), optional query (JMESPath to trim the response).paginate -- one page of a list/describe operation, returning a nextToken. The model issues the next call with the token.logs_tail -- the one operation-specific helper; wraps aws logs tail --format json because CloudWatch Logs is annoying enough through call that a bespoke tool earned its keep.Auth and session management:
whoami -- current identity (account, ARN), profile, region, and SSO token expiry countdown. Call this first.login_start / login_complete -- device-code SSO flow with no browser spawn from inside the subprocess. The model surfaces the URL and 8-character code; the user clicks once.refresh_if_expiring_soon -- check the cached SSO token and auto-start a refresh when fewer than thresholdMinutes minutes remain.session_set / session_get / session_clear -- per-session profile and region defaults ("switch to prod," "use us-west-2") that override env vars but stay scoped to this MCP session.list_profiles -- list profiles configured in ~/.aws/config.assume_role -- call STS AssumeRole and stash the temp creds as a new profile (mcp-<sessionName>) in ~/.aws/credentials. The secret stays on disk and is never returned to the model.The shape is: one server, one config entry, the same eighteen tools cover hundreds of resource types and the entire AWS CLI surface. I do not chase per-service typed wrappers. AWS Labs ships a fleet of those at awslabs/mcp and the two are designed to coexist -- if you need typed Lambda invoke or DynamoDB type-marshalling, drop one of those into your MCP config alongside this one.
Auth is the part that took me longest to get right and is the part the README spends the most words on. The server uses the AWS SDK's standard credential chain (@aws-sdk/credential-providers) to read credentials, but its real auth interface is the SSO device-code flow and the ~/.aws/config profile graph -- because that is where credentials actually live for the people using the server, and it is what aws sso login mutates when an SSO session expires mid-conversation.
The reason this matters: when an SSO token expires, aws sso login tries to open a browser from a subprocess. On Windows and on a lot of WSL setups, that handoff drops silently. The user is then stuck context-switching to a terminal, running the command themselves, and coming back. The --no-browser device-code flow fixes this -- the assistant surfaces a short URL and an 8-character code, the user clicks one link in their own browser, and the session resumes -- and that whole flow runs through the login_start / login_complete / refresh_if_expiring_soon triplet without the model needing to know anything about the underlying SSO mechanics.
The actual AWS calls are split between the SDK (used inside whoami for STS:GetCallerIdentity) and a subprocess of the aws CLI (used by every resource tool, by call, by paginate, by logs_tail). Spawning the CLI sounds heavyweight but pays for itself: the CLI is the source of truth for "every AWS service in kebab-case," it knows about new services the day AWS adds them, and it handles the wire format and pagination for me. The cost is one subprocess per call. The benefit is no @aws-sdk/client-* dependency tree to keep current and no per-service tool sprawl to maintain.
I considered (and built, and deleted) a single-server multi-account design where the tool would take an account argument and the server would assume into the right role internally. The reasoning to delete it: "one server, many accounts" sounds clean but means the server is a sudo shim, and a single failure of confused-deputy reasoning gives the model the union of all my AWS access. The replacement is assume_role, which makes role assumption an explicit tool call the user can see and approve, and which writes the temp credentials to a named profile the user can audit. The blast radius stays bounded by AWS IAM, not by my tool code.
Transport is stdio. Hosting and deployment follow the same pattern as the other servers.
Mistake 1: A per-service tool taxonomy I had to dismantle.
The first version of this server was the obvious shape: per-service tools. I had ec2_list_instances, ec2_stop_instance, s3_list_buckets, s3_list_objects, iam_list_users, iam_get_role, and a dozen more. Each one wrapped the corresponding @aws-sdk/client-* SDK call. It looked clean on paper and scaled to about five services before falling over.
The problem was twofold. First, the tool count was a tax on every prompt. By the time I had the surface I actually wanted -- maybe forty tools across ten services, before I had even touched the half-dozen services I planned to add next -- the model's tool-selection rate had degraded noticeably. Second, AWS keeps shipping services. Every new service the user wanted to touch was a new file, a new SDK dependency, a new round of tool descriptions, a new set of edge cases. The maintenance shape was wrong.
I ripped the per-service surface out in v0.3 and replaced it with the generic resource router (resource_get/list/create/update/delete/status) plus call. The resource router uses Cloud Control API, which is AWS's own generic CRUD layer over CloudFormation-shaped resource types -- the same resource_get call works for AWS::Lambda::Function, AWS::S3::Bucket, AWS::IAM::Role, AWS::SSM::Parameter, and a few hundred more. For anything CCAPI does not cover (data-plane operations like S3 reads, Lambda invokes, Bedrock inference) the model uses call to hit the AWS CLI directly with service and operation arguments.
This trades surface area for description complexity. The descriptions for resource_get and call have to do more work, because they are universal -- they cover everything the per-service tools used to cover, and the model has to choose between them based on whether the operation is "managed by CloudFormation" (use resource_*) or "data-plane / not in CFN schema" (use call). The descriptions name that choice explicitly, and the model gets it right almost every time.
From the field: When the upstream is "every API in a cloud provider," a per-service tool surface ages badly. The taxonomy you start with is wrong by the time the cloud has shipped six new services. A generic CRUD layer plus an escape hatch is more work to design but ages a lot better, because the layer absorbs new services without any tool changes at all.
Mistake 2: The description rewrite that bought me a 20-point eval jump.
After the v0.3 generic-router restructure I ran a 200-prompt eval. The prompts were real things I and a couple of beta users had asked the model to do across our AWS setups: "list the public buckets in this account," "stop the dev instance in us-west-2," "what roles can read the artifacts bucket," "create an SSM parameter named /app/version with value 0.4.1," "show me the last hour of logs for the api lambda." For each prompt I had a known-correct answer for which tool the model should pick first.
The early v0.3 version scored around 70%. The wrong picks fell into recognizable categories. The model would reach for call when the operation was clearly CCAPI-shaped (anything Get*, Create*, Update*, Delete* on a control-plane resource), because the call description was more concrete-sounding than the abstract resource_* descriptions. It would reach for resource_list for data-plane queries (S3 object listings, DynamoDB scans) where CCAPI does not have coverage. It would reach for resource_get when the user wanted a status check rather than the resource body, missing resource_status entirely.
I spent a weekend rewriting every tool description. The rules I was applying, in retrospect:
resource_* descriptions all say "via Cloud Control API"; the call description says "via the aws CLI"; the model has a second handle for routing.call description says "for high-level wrappers like 'aws s3 cp' or 'aws ec2 wait', use your shell"; the resource_* descriptions say "for resources not covered by CCAPI or for data-plane operations, use call." The negative space is half the routing signal.resource_status description says "Poll an async CCAPI request by requestToken" so the model does not confuse it with resource_get.After the rewrite, the same eval scored in the low 90s. The model was not smarter. The descriptions were just less ambiguous about which tool to pick when the operation could plausibly map to two of them.
From the field: Tool descriptions are the prompt you write once and the model reads forever. Treat them like product copy: every word costs context, every misleading word costs a wrong tool selection. If you have an eval and your tool selection rate is below 85%, the descriptions are almost always the cheapest fix.
Mistake 3: The cancellation bug that ran a multi-region paginate for minutes after Ctrl-C.
resource_list paginates through one region's slice of a CCAPI type. A user (me) wrote a quick "list all the Lambda functions across every region we deploy to" wrapper that called resource_list in parallel across all eight of our regions and stitched the results. The wrapper itself was scripted on top of the MCP server, not a tool inside it -- so far so good.
The first version of resource_list, though, was checking ctx.signal.aborted once at the start of the handler, not on every paginated page within it. When the user hit Ctrl-C on the wrapper, the wrapper cancelled the outer batch, but each of the eight in-flight resource_list calls kept paginating through that region's results, because nothing inside the handler was watching the signal. For a region with thousands of resources of the requested type, that meant another minute or two of aws cloudcontrol list-resources subprocess calls -- and the AWS bills that go with them -- after the user thought they had stopped.
I plumbed AbortSignal through every page of every paginated call in v0.4, and through the runAwsCall subprocess wrapper so that aborting kills the underlying aws subprocess too. The shape that ships today is: every tool handler accepts the request signal, every loop checks it on every iteration, every subprocess gets killed when the signal fires.
I also stopped writing fan-out tools entirely. The "list across all regions in one call" shape is tempting and wrong: it concentrates the cancellation, fan-out, retry, and partial-failure complexity in my code, instead of letting the model coordinate eight independent tool calls and decide for itself when to stop. The model is better at that coordination than I am at threading signals through fan-out code.
Mistake: A "convenience" tool that fans out across N regions or N services is also a tool that fans out N times the blast radius of every bug, including cancellation bugs. If the model can do the fan-out itself by calling N separate tools, let it. The cancellation surface for the model is sharper -- it can stop issuing the next call rather than rely on me to plumb a signal through one big handler.
aws-mcp organizes those tools across nine flat files in src/tools/ -- one file per tool family, not one file per tool: resource.ts (the six CCAPI tools, plus the polling helpers; this is the largest file), call.ts, paginate.ts, logs.ts, auth.ts (the SSO triplet plus whoami), session.ts (the per-MCP-session profile/region overrides), profiles.ts, assume.ts, and a small tool.ts with the shared Tool and ToolResult types. Around the tools sit aws-cli.ts (the subprocess wrapper -- spawns the aws CLI, plumbs AbortSignal to kill the child on cancel, parses output), aws-credentials.ts, sso.ts (the device-code flow), and the top-level session.ts that holds the in-memory defaults.
There is no per-service SDK dependency. The runtime has @aws-sdk/client-sts for whoami and assume_role, and the only other AWS-specific dependency is the aws CLI on the user's machine. That is a deliberate choice: the CLI is the source of truth for "every AWS service in kebab-case," it stays current with new services automatically, and shelling out keeps the bundle small enough to npx -y cold-start in under a second.
I have a backlog of maybe a half-dozen more tools I would like to add (a typed s3_get_object for binary downloads, a cost_query for Cost Explorer one-liners, a couple more auth helpers). I add them slowly because every addition is a line item in the model's tool-selection problem and the current shape -- generic CRUD plus an escape hatch -- is one I do not want to dilute.
The tool worth showing is resource_get. It is the most-called tool in the server, the one whose description had to do the most work in the v0.3-to-v0.4 rewrite, and a clean example of the "wrap the AWS CLI subprocess" shape that the rest of the server follows.
// src/tools/resource.ts (excerpt)
{
name: 'resource_get',
description:
"Read a single AWS resource via Cloud Control API. Covers hundreds of resource types " +
"with a CloudFormation schema. `typeName` is '<Namespace>::<Service>::<Resource>' " +
"(e.g. 'AWS::Lambda::Function'); `identifier` is the primary key for that type " +
"(function name, bucket name, IAM role name, ARN, or composite id). Returns parsed " +
"Properties. For resources not covered by CCAPI or for data-plane operations, use call.",
annotations: {
title: 'Get an AWS resource by type + identifier',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
inputSchema: z.object({
typeName: z.string().describe(
"CloudFormation type name, e.g. 'AWS::Lambda::Function', 'AWS::S3::Bucket', 'AWS::IAM::Role'.",
),
identifier: z.string().min(1).describe(
'Primary identifier for the resource (function name, bucket name, ARN, or composite id).',
),
profile: z.string().optional().describe('Override session profile for this call.'),
region: z.string().optional().describe('Override session region for this call.'),
timeoutMs: z.number().int().positive().optional().describe(
'Timeout in milliseconds. Default 60000.',
),
}),
handler: async (input) => {
const tnErr = validateTypeName(input.typeName);
if (tnErr) return { ok: false, error: tnErr };
const idErr = validateIdentifier(input.identifier);
if (idErr) return { ok: false, error: idErr };
const result = await runAwsCall({
service: 'cloudcontrol',
operation: 'get-resource',
profile: input.profile,
region: input.region,
timeoutMs: input.timeoutMs,
outputFormat: 'json',
extraFlags: ['--type-name', input.typeName, '--identifier', input.identifier],
});
if (!result.ok) {
return { ok: false, error: result.error, rawBody: result.rawStderr ?? result.rawStdout };
}
const raw = result.data as { TypeName?: string; ResourceDescription?: unknown } | null;
const parsed = parseResourceProperties(raw?.ResourceDescription);
return {
ok: true,
data: {
command: result.command,
typeName: raw?.TypeName ?? input.typeName,
identifier: parsed.Identifier,
properties: parsed.Properties,
},
};
},
},
The description is doing the heaviest lifting. The first sentence says what the tool does and names the upstream surface (Cloud Control API). The second tells the model exactly how to construct typeName and identifier -- the most common failure mode is the model hallucinating a type name like EC2::Instance instead of AWS::EC2::Instance, and naming the format heads that off. The last sentence draws the line between this tool and call: data-plane operations (anything not covered by CCAPI) belong on the other side of that line.
The handler is short by design. It validates inputs (the validate* helpers reject obviously-malformed type names and identifiers before they reach the subprocess, so the model gets a useful error fast). It hands off to runAwsCall, which is the single place that knows how to spawn the aws CLI, plumb AbortSignal to kill the child process on cancellation, parse stdout, and shape errors. The tool returns a structured result with the parsed Properties at top level and the literal command string echoed back -- the model can copy that into the user's terminal if it needs to escalate or hand control back. The tool itself is twenty lines of routing; the load-bearing complexity sits one layer down in runAwsCall, where it can be tested and reused by every other tool in the server.
LemonSqueezy is the merchant of record I use for Yaw Labs subscriptions. The product surface is small (a handful of products, a license-key model, webhooks for entitlement events), but the configuration surface is wide and most of it is buried in the dashboard. I would log in to do a thing, find the thing, do the thing, and log out, and a week later I would do it again from scratch because I had not memorized the path.
The MCP server is a thin shim over the LS REST API. Most of the daily ops are reads -- "show me last week's orders," "what variants does this product have," "did this customer's webhook actually fire" -- and the dashboard is fine for those when I happen to be in it, but it is not always where I am. The win is in the operations I do irregularly enough that I never remember where the button is, plus the operations that LS exposes through the API but not particularly conveniently in the dashboard, like license-key activation flows and webhook configuration.
The tool surface tracks the LS API surface fairly closely, organized by entity:
Products, variants, prices, files:
get_product, list_products (filter by store)get_variant, list_variants (filter by product)get_price, list_prices (filter by variant)get_file, list_filesOrders and order items:
get_order, list_orders (filter by store, customer, email, status)generate_order_invoice, refund_orderget_order_item, list_order_itemsCustomers:
get_customer, list_customerscreate_customer, update_customer, archive_customerSubscriptions:
get_subscription, list_subscriptionsupdate_subscription, cancel_subscriptionget_subscription_item, list_subscription_items, update_subscription_item, get_subscription_item_usageget_subscription_invoice, list_subscription_invoices, generate_subscription_invoice, refund_subscription_invoiceget_usage_record, list_usage_records, create_usage_recordDiscounts:
get_discount, list_discounts, create_discount, delete_discountget_discount_redemption, list_discount_redemptionsLicense keys (the API key path):
get_license_key, list_license_keys, update_license_keyget_license_key_instance, list_license_key_instancesLicense operations (the license-key-as-auth path, no API key required):
activate_license, validate_license, deactivate_licenseCheckouts, webhooks, stores, affiliates, users:
get_checkout, list_checkouts, create_checkoutget_webhook, list_webhooks, create_webhook, update_webhook, delete_webhookget_store, list_storesget_affiliate, list_affiliatesget_userSixty-one tools in total. The license operations are the only ones that authenticate with the license key itself rather than the store API key -- that distinction matters for the code excerpt later in this section and for the "Mistakes" subsection.
The list of things that aren't here matters too. There is no simulate_webhook (LS doesn't expose a webhook-simulation endpoint, and a fully-synthetic implementation would be a lot of fragile per-event templates with no guarantee they stay shaped like the real ones). There are no order-bump tools (LS exposes order bumps in the dashboard but not in the public API). There is no set_price or create_variant write operation (the LS API treats variants and prices as mostly-immutable post-creation; you build them in the dashboard and read them via the API). The shape of the server is bounded by the shape of the upstream surface.
Auth is a single LEMONSQUEEZY_API_KEY env var. LS uses bearer-token auth with a long-lived API key. There is no OAuth dance for first-party tooling. The server reads the key at startup and includes it in every request to https://api.lemonsqueezy.com/v1.
The HTTP layer is fetch against the LS API with Accept: application/vnd.api+json and a per-request retry helper for transient failures. The wire format is JSON:API throughout -- requests and responses both -- and the server passes that envelope through to the model rather than flattening it (more on that under Mistakes).
The license-operations endpoints are the one part of the surface that authenticates differently. activate_license, validate_license, and deactivate_license use the license key itself as the credential and post application/x-www-form-urlencoded to /v1/licenses/*. This matters because those are the endpoints an end user's installed software calls when it boots up and wants to confirm the license is still good -- that path runs without any LS API key on the user's side. Exposing them through MCP means I can debug a customer's "my license stopped working" report without copying their key into a curl command.
Mistake 1: Variant/bundle modeling.
LS has a product/variant model. A "product" is a thing like "Yaw Labs Pro"; a "variant" is a specific SKU like "Yaw Labs Pro Annual" or "Yaw Labs Pro Monthly." Within a variant you can have multiple prices (different currencies, A/B tests, grandfathered tiers).
In v0.1 I conflated product and variant in my tool surface. list_products returned a flat list with the first variant's price stitched in, mashing the product/variant hierarchy. My excuse was "the model does not need to know about the hierarchy." This was wrong. The model needed to know about the hierarchy because every operation on a price has to be issued against the variant ID, not the product ID, and my flattened list was not surfacing the right ID.
I fixed it by exposing the hierarchy as it exists in LS: separate list_products, list_variants, and list_prices tools, each returning the entity at its own level, plus the JSON:API include parameter that LS exposes for inlining related resources -- if the model wants products with variants nested, it passes include: 'variants' and gets the JSON:API included array back. The IDs you get are the IDs you need. The model can stitch the hierarchy itself if it wants to. The data model in the tool surface should match the data model in the backend, not a "simplified" version of it.
Mistake 2: I tried to flatten JSON:API responses to "simpler" shapes.
The LS API is JSON:API throughout. Every response comes back wrapped in a data envelope with attributes, relationships, and (when included) a sibling included array. In v0.1 I had a transform layer that flattened these into a "cleaner" shape -- attributes promoted to the top level, relationships replaced by a *Id field, the included array merged in. My excuse was "the model does not need to know about JSON:API."
This was wrong. It broke twice. The first time, LS added two new fields to the attributes block of subscriptions -- the new fields landed unmodified at the top level of my flattened object, but my flattener was also stripping anything it didn't recognize, so the new fields disappeared on the way through. The second time was worse: LS shipped a relationship I was post-processing into a customerId field, except they shipped it sometimes nested under a different relationship key, and my mapper missed half the cases. Subscriptions came back with customerId: undefined for about a week before I noticed, by which point a couple of users had reported "the customer field is missing."
I deleted the flattener in v0.3. The tools now pass the JSON:API envelope straight through -- data.attributes, data.relationships, included -- and the model navigates it fine. The first time I worried that the model would get confused by the structure; the actual experience is that the model is comfortable with structured data and uncomfortable with shapes I made up locally that diverge from anything documented. The version-stability lesson: pass the upstream shape through. If you must transform, do it in one place that you test against the upstream's full envelope, not in tool-specific mappers that drift apart over time.
Mistake 3: API key in env was right; "API key per environment" was right; "API key per environment per server instance" was overkill.
For about a week I had a complicated config layer that supported multiple API keys -- one for prod, one for staging, one for development -- selectable via a LEMONSQUEEZY_ENV switch. The idea was that I could have one server instance and switch which LS account it was talking to.
This was the same mistake as the multi-account aws-mcp design: it makes the server a sudo shim over a sensitive credential set. I deleted it in favor of one server per environment, with the API key set in the env var for that environment. The LS dashboard does not have a "multi-environment" concept anyway; each project has its own LS account. The complicated config was a solution to a problem that did not exist.
lemonsqueezy-mcp organizes those tools across roughly twenty files in src/tools/, one file per LS resource type (products.ts, variants.ts, prices.ts, orders.ts, subscriptions.ts, subscription-invoices.ts, subscription-items.ts, usage-records.ts, customers.ts, discounts.ts, discount-redemptions.ts, license-keys.ts, license-key-instances.ts, licenses.ts, checkouts.ts, webhooks.ts, affiliates.ts, stores.ts, users.ts, files.ts, order-items.ts). The HTTP layer is src/api.ts -- it owns the JSON:API envelope handling, the bearer-token header, the retry loop on 429/5xx, and a pair of higher-order helpers (getHandler and listHandler) that the simple resource-by-id tools share so I do not write the same fifteen-line handler twenty-one times. Around it sit secret.ts (loads the API key with rotation hooks), guardrails.ts (the destructive-rate-limit and store-scope checks), and logger.ts / retry.ts.
I expect to extend this server faster than the others, because LS keeps shipping new features (subscription pause, prorated upgrades, multi-currency improvements) and each one is a few new tool calls. The server's churn rate is higher; the server's bug rate is lower than aws-mcp because the surface is shallower and the upstream's wire format is consistent enough that one HTTP layer covers everything.
The shape worth showing is the license-operations file. These three tools are the only ones in the server that authenticate with the license key itself rather than the LS API key, and they are the ones an installed end-user product hits in the field. They are also a good shape demonstration: a complete tool family in seventy lines, no class hierarchy, no per-tool framework, three handlers calling one shared licenseRequest helper.
// src/tools/licenses.ts
import { z } from 'zod';
import { licenseRequest } from '../api.js';
export const licenseTools = [
{
name: 'activate_license',
description:
'Activate a license key for an instance. Does not require an API key -- ' +
'uses the license key itself for auth.',
annotations: {
title: 'Activate license',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
inputSchema: z.object({
licenseKey: z.string().max(10000).describe('The license key to activate'),
instanceName: z
.string()
.max(10000)
.describe('A name for this activation instance (machine name, user identifier)'),
}),
handler: async (input) => {
return licenseRequest('/licenses/activate', {
license_key: input.licenseKey,
instance_name: input.instanceName,
});
},
},
{
name: 'validate_license',
description:
'Validate a license key or specific instance. Does not require an API key.',
annotations: {
title: 'Validate license',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
inputSchema: z.object({
licenseKey: z.string().max(10000).describe('The license key to validate'),
instanceId: z.string().max(10000).optional().describe(
'Optional instance ID to validate a specific activation',
),
}),
handler: async (input) => {
const body: Record<string, string> = { license_key: input.licenseKey };
if (input.instanceId !== undefined) body.instance_id = input.instanceId;
return licenseRequest('/licenses/validate', body);
},
},
{
name: 'deactivate_license',
description:
'Deactivate a license key instance. Does not require an API key.',
annotations: {
title: 'Deactivate license',
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
inputSchema: z.object({
licenseKey: z.string().max(10000).describe('The license key'),
instanceId: z.string().max(10000).describe('The instance ID to deactivate'),
}),
handler: async (input) => {
return licenseRequest('/licenses/deactivate', {
license_key: input.licenseKey,
instance_id: input.instanceId,
});
},
},
] as const;
Two details are worth calling out. First, the descriptions name "does not require an API key" up front, because that is the load-bearing distinguisher between this tool family and the rest of the server -- if the model is debugging a customer's license problem, the activate/validate/deactivate trio is the path to use, and pointing it at update_license_key (which does require an API key and edits server-side state) would be wrong. Second, licenseRequest -- defined in src/api.ts -- is the form-encoded variant of the JSON:API client, separated out because the LS license endpoints take application/x-www-form-urlencoded while the rest of the API takes application/vnd.api+json. That split lives in one place and the tool handlers stay short.
Four servers, four backends, four versions of "now I am rebuilding the same thing again." If I distill the patterns that repeated, mistakes I keep almost-making, and rules that emerged into a single section, it looks like this.
One server, one auth scope. Every time I tried to make a single server multi-tenant or multi-account I regretted it. The right shape is one server instance per credential, with the credential's blast radius bounded by the upstream's IAM. If you want multi-account, run multiple instances; do not let the server be the thing that decides which account.
Tool descriptions are the prompt you write once. The roughly 20-point eval jump on aws-mcp was not from changing any code. It was from rewriting descriptions. Treat them as the most important strings in the codebase. Every tool I have shipped since uses the same description shape: open with the operation, name the upstream API, call out what is and is not in scope, and give one sentence of context for when to use this over a sibling tool.
Cancellation has to be plumbed end-to-end. The flag-and-check-in-the-loop pattern is not enough; the slow part of every tool is a network call, and the network call has to be cancellable at the socket layer. Use AbortSignal everywhere. Pass it into every fetch, every SDK command, every paginator. The cost of doing this on day one is small. The cost of retrofitting it is, in my experience, about a weekend per server.
Match the upstream data model in the tool surface. Flattening JSON:API into "simpler" shapes broke my LS server twice (once on a subscriptions field that landed unrecognized, once on a relationship I was post-processing into a flat customerId). Treating HuJSON as plain JSON broke my Tailscale server because real ACLs carry comments and trailing commas the way operators actually write them. The model is fine with hierarchy and with permissive formats; the model is bad with my made-up shapes that diverge from the upstream's. Pass the shape through; document the structure in the tool description; let the model navigate it.
The "convenience" fan-out tool is a trap. Every server I have built has had a tempting "do this across N regions / accounts / stores in one call" tool, and every time I have shipped one, the cancellation, fan-out, and partial-failure behavior has been wrong in some non-obvious way -- as it was in aws-mcp's resource_list paginating across regions in parallel. If the model can compose N tool calls itself, let it; the model is better at coordinating cancellation and parallelism across N independent calls than I am at threading a signal through one big one. Reserve fan-out tools for cases where the model genuinely cannot orchestrate (because the data flow has to be in-process for performance reasons) and even then, plumb cancellation more carefully than you think you need to.
Adding tools because they are easy. Every server I build has a backlog of "I could add..." that I have to actively resist. Each added tool is a line item in the tool-selection problem the model solves on every request. Tool count is not free; it is a tax on every prompt the model sees.
Hiding tools dynamically because there are too many. This is the wrong fix for the previous problem. Reduce surface area by deleting tools, not by hiding them. A non-deterministic tool surface is worse than a long one.
Wrapping the upstream's quirks in code without enough evidence first. The flip side of "encode the rule in the wrapper" is "encode the wrong rule in the wrapper." The npmjs-mcp deprecation-format normalizer was the textbook example: I had two failure data points, drew a line between them, and shipped a check that produced false positives every time anyone wrote a normal English deprecation message. Wrap the documented rules; surface the undocumented ones in the error path so the model can route on them; do not turn a hunch into a validator with one user-visible behavior change per release. If you are wrapping a backend with opaque error responses, get a third data point that isolates the variable before you encode the rule.
Building the multi-environment selector before I have multiple environments. I do this maybe once a year. The complicated config layer that selects between prod and staging is fun to build and has zero users until the day it has many users, and that day is usually six months later than I thought it would be. Until then, it is dead code that I have to keep alive. One env, one config; multiply by running multiple instances when the time comes.
These are the rules I now apply to every new MCP server I start. They are not novel; most of them appeared somewhere in the chapters before this one. What is new is that I trust them, because each one was a mistake first.
Auth is per-server, scoped at the credential. Read the credential from env at startup. Refuse to start if it is missing. Do not invent a config layer that lets one server speak for many credentials.
Tool descriptions are product copy. Open with the operation. Name the upstream API. Say what is and is not in scope. Give one sentence of context. If you have an eval, run it; if your selection rate is below 85%, rewrite descriptions before you touch code.
Annotations are not optional. Every destructive tool gets destructiveHint: true. Every idempotent tool gets idempotentHint: true. Every tool that hits external state gets thought about for openWorldHint. The annotations are how the client tells the user this is a thing they should pay attention to before approving.
AbortSignal everywhere. Every fetch, every SDK command, every paginator. The signal is part of the call signature, not an afterthought.
Match the upstream's data model. Pass through JSON:API envelopes, hierarchies, multi-document shapes. The model can navigate; your made-up shape cannot.
One backend per server. If you want to wrap two backends, write two servers. The auth boundary, the tool surface, and the failure modes are all different; conflating them in one server saves a deploy and costs everything else.
Resources earn their keep or they go. If a resource is not the right substrate for the data, do not ship it as a resource just because it is nominally readable.
ASCII output for terminal codepaths. Em-dash, bullet, curly quote, mathematical comparison glyphs -- they all mojibake on Windows ConPTY. The chapter you are reading uses ASCII in the prose and the code blocks for the same reason; my style guide is also my server's style guide, because the same characters end up flowing through the same shells either way.
Read-then-fix beats build-then-rewrite for descriptions. The first version of every tool description I write is bad. The second version, written after I have run it through a real prompt eval and seen the wrong tool get picked, is much better. Plan for two versions.
Delete more than you add. Every server in this chapter is smaller than its v0.1. Every one of them got better when I deleted things. The shape of an MCP server should converge on small.
The shortest version of this chapter, the version that fits on an index card, is: build the server, ship it, watch it fail in interesting ways, delete half of what you built, and write down what you learned. Do that four times and the fifth server takes a tenth of the time. I am still learning. The servers are still shrinking. The model is still picking the wrong tool occasionally and I am still rewriting the description to fix it.
The next chapter -- the last in this part of the book -- is about what I think is going to change in the protocol over the next year, what tooling I think is missing, and where I think MCP is going to bite production teams next. It is more speculative than this one. This one is the receipts.