MCP in Production · Chapter 4
It is 4:47 PM on a Friday. I have just tagged @yawlabs/aws-mcp@0.3.0, pushed to main --follow-tags, and watched the GitHub Actions release workflow paint itself green for the build job and red for the publish job. The error was four lines long.
npm ERR! code EOTP
npm ERR! errno EOTP
npm ERR! This operation requires a one-time password from your authenticator.
npm ERR! Use `npm profile enable-2fa auth-and-writes` to set up 2FA.
I had just enabled 2FA. That was the whole point. I had logged into npm with WebAuthn six minutes earlier, in the same terminal that had successfully published @yawlabs/tailscale-mcp thirty minutes before. The token in ~/.npmrc was, to all appearances, identical. The package was identical in shape to four others I had shipped that month.
I retried. Same error. I retried again. Same error. I started typing a long Slack message about how the WebAuthn session must not be propagating, how perhaps I needed to rebuild my npmrc from scratch, how the org token might be stale -- the kind of theory you spin up at 4:53 PM on a Friday when you really want to ship before the weekend. I deleted the message and retried one more time. It published.
There was no fix. There was no user action between attempts three and four. The auth backend simply had not caught up to my session yet, and after about ninety seconds it had. The error message did not say "your session is propagating, retry in thirty seconds." It said "this operation requires a one-time password," which was a lie.
This is the npmrc class of bugs. A credential that works locally, fails in CI or fails on the next call, and produces an error message designed for a different problem than the one you actually have. MCP servers are absolutely riddled with this class of bug, because every MCP server is, fundamentally, a thin shim that takes credentials from one place and uses them to talk to another place, and every layer of that shim has its own opinions about what credentials look like and when they should be considered valid.
This chapter is about how to handle credentials in MCP servers without inheriting that class of bug. We will cover where secrets enter the server, how stdio and HTTP transports differ in their credential models, OAuth 2.1 + PKCE for HTTP MCP servers, scoped tokens, multi-tenancy, and the specific ways auth code goes wrong in production. By the end you should know not just the patterns but the failure modes, because the patterns are easy and the failure modes are what actually wake you up at 2 AM.
A secret enters an MCP server through one of exactly three doors. Knowing which door you are standing at determines almost everything else about your auth code -- the storage model, the failure modes, the multi-tenancy story, the testing strategy. Mix the doors up and you will spend a Saturday afternoon debugging an auth bug that never reproduces locally.
The first and oldest door. The server starts up, reads process.env.GITHUB_TOKEN (or whatever), and uses it for every tool call until the process dies. This is the default for almost every stdio server I have shipped. It is the model used by @yawlabs/lemonsqueezy-mcp, @yawlabs/ctxlint, and the GitHub-token half of @yawlabs/npmjs-mcp.
Env vars are simple, they survive claude mcp add ... -e, they map cleanly to twelve-factor patterns, and they are completely wrong for anything multi-tenant. If your server is meant to serve a single user from their laptop, env vars are the right answer. If your server is meant to serve thirty users from a Fly.io machine, env vars will get you a SEV.
The second door was made mandatory in the 2025-03-26 spec revision (OAuth 2.1 with PKCE, required for every HTTP-transport MCP client) and refined in 2025-06-18 (Protected Resource Metadata, the resource parameter, the formal split between resource server and authorization server). The server speaks OAuth 2.1 with PKCE, the client fetches an access token, the client passes it on every HTTP request as Authorization: Bearer <token>, and the server validates the token against an authorization server. This is the only door that scales to multi-tenant HTTP-transport MCP servers without sharing credentials across tenants.
OAuth is also the door that produces the most spectacular failures. Token refresh, reconnect persistence, and key rotation are exactly where remote MCP servers break in production; we will go deep on the patterns later in the chapter.
The third door is the one nobody documents. When you configure an MCP server in your client (Claude Code, Cursor, Cline, Zed, etc.), the client passes credentials to the server through the client's own configuration mechanism -- usually as command-line args or environment variables in the spawn config. This is structurally an env-var pattern, but the credential lifecycle is the client's problem, not yours. You read it once at startup; the client decides when to rotate.
The interesting case is claude mcp add with the -e flag, which stuffs an env var into the spawn config for that server. From the server's perspective it is identical to door one. From the user's perspective it means the credential lives in the client's config file (~/.claude.json or similar), not in their shell rc. This matters for security review and for the "where is my secret stored" question, which is more often than not the first question a security team asks.
Most of the MCP servers I have shipped are stdio servers. They are spawned as subprocesses by an MCP client, they speak JSON-RPC over stdin/stdout, and they die when the client disconnects. Their auth model is dead simple and almost always correct: the server runs as the user, with the user's environment, and inherits whatever credentials the user has already configured for the underlying tool.
This is the local-credentials inheritance pattern, and it is the most underrated design pattern in the MCP ecosystem.
Consider @yawlabs/aws-mcp. The server does not ask for AWS credentials. It does not have an AWS_ACCESS_KEY_ID env var. It does not prompt the user for an IAM role. It calls new S3Client({}) with no arguments, and the AWS SDK does what AWS SDKs do: walks through ~/.aws/credentials, ~/.aws/config, the env, the EC2 metadata service, and so on, in priority order, until it finds a credential it can use. If the user has run aws configure sso, the server uses their SSO session. If the user has AWS_PROFILE=prod in their shell, the server uses the prod profile. If the user is on an EC2 instance with an instance profile, the server uses the instance profile.
The same pattern applies to @yawlabs/electron-mcp (uses the user's local Electron install) and to a dozen other servers I have not shipped yet but probably will. The pattern is "the underlying tool already has a battle-tested credential chain; inherit it, do not reinvent it." Servers that wrap a backend without a chain -- LemonSqueezy, Tailscale's Admin API -- have to fall back to door one or door two; we will get to those next.
// @yawlabs/aws-mcp - no credential code at all
import { S3Client, ListBucketsCommand } from "@aws-sdk/client-s3";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({ name: "aws-mcp", version: "0.3.0" });
server.registerTool(
"s3_list_buckets",
{
description: "List all S3 buckets visible to the current AWS credentials.",
inputSchema: {},
},
async () => {
// No region, no credentials, no profile -- the SDK figures it out
const client = new S3Client({});
const response = await client.send(new ListBucketsCommand({}));
return {
content: [{ type: "text", text: JSON.stringify(response.Buckets) }],
};
},
);
There is no if (!process.env.AWS_ACCESS_KEY_ID) throw new Error(...). There is no config file to load. There is no auth code. The server inherits the user's environment and trusts the SDK to do the right thing.
From the field: the first version of
@yawlabs/aws-mcphad a 40-line credential resolver that read AWS_PROFILE, parsed~/.aws/credentialsdirectly, and fell back to env vars. It was wrong in three different ways and missed SSO entirely. I deleted it the day I realized the AWS SDK already does this and is better at it than I ever will be. The lesson: if the underlying tool has a battle-tested credential chain, do not reimplement it. Inherit it.
The trade-off is that this pattern only works when the tool you are wrapping has a credential chain. AWS does. Tailscale does. GitHub does (sort of, through gh auth status). LemonSqueezy does not -- there is one API key, full stop. For tools without a chain, fall back to door one.
The spawn environment a stdio server inherits is the environment of the parent process, which is the MCP client, which may or may not be the same as the user's interactive shell. On macOS and Linux, GUI apps spawned from Finder or Spotlight do not inherit the shell's env vars. On Windows, the spawn environment is a snapshot taken when the parent process started, which means restarting the client picks up new env vars but reloading a config file does not.
This is why claude mcp add -e KEY=value exists. It puts the env var in the spawn config so the client passes it to the server explicitly, regardless of whether the user's shell has the var.
# Right way: stuff the var into the spawn config
claude mcp add aws-mcp \
-e AWS_PROFILE=prod \
-e AWS_REGION=us-east-1 \
-- npx -y @yawlabs/aws-mcp
If your README tells users to "set MYTOOL_API_KEY in your shell," half of them will set it in zsh and launch Claude Code from the dock and wonder why it does not work. Tell them to use claude mcp add -e MYTOOL_API_KEY=... and the problem goes away.
HTTP MCP servers play by completely different rules. The server is long-running. It serves multiple clients. Each client brings its own credentials. The server cannot inherit the user's environment because the server does not have a user -- it has a TLS certificate and a load balancer in front of it.
The model is per-session credentials. Each connection arrives carrying a token (typically Authorization: Bearer <jwt>), the server validates that token, and tool calls executed on that session use the token's identity for downstream API calls. The server itself has no global credential for the upstream API; it has a per-tenant credential vended by the auth flow.
// HTTP MCP server, simplified
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
interface Session {
userId: string;
accessToken: string; // for the upstream API, e.g. GitHub
expiresAt: number;
}
async function handleHttpRequest(
req: IncomingMessage,
res: ServerResponse,
body: unknown,
) {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
res.statusCode = 401;
res.end("Missing token");
return;
}
const token = auth.slice("Bearer ".length);
const session = await validateAndLoadSession(token);
if (!session) {
res.statusCode = 401;
res.end("Invalid or expired token");
return;
}
// Build a server whose tool handlers close over this session's credentials,
// and connect it to the streamable HTTP transport for this request.
const server = buildServerForSession(session);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await server.connect(transport);
await transport.handleRequest(req, res, body);
}
The session lookup is the load-bearing piece. It must be fast (this runs on every request), it must validate the token cryptographically (do not just trust the bearer string), and it must return enough context for tool handlers to act on behalf of that user.
Inside a tool handler, you reach for the session, not for process.env. The clean way to do that is to build the server per-session and let the handlers close over the credentials:
function buildServerForSession(session: Session) {
const server = new McpServer({ name: "github-tools-mcp", version: "1.0.0" });
server.registerTool(
"gh_list_repos",
{
description: "List the authenticated user's repositories.",
inputSchema: {},
},
async () => {
const octokit = new Octokit({ auth: session.accessToken });
const repos = await octokit.repos.listForAuthenticatedUser();
return { content: [{ type: "text", text: JSON.stringify(repos.data) }] };
},
);
return server;
}
The cardinal sin in HTTP MCP servers is reaching for a global credential inside a tool handler. If your server has a top-level const GITHUB_TOKEN = process.env.GITHUB_TOKEN and uses it inside a tool handler, you have just turned a multi-tenant server into a single-tenant server that pretends to be multi-tenant. Every user's tool calls execute as the same GitHub user. This is, in the literal sense, a confused deputy vulnerability, and it is the kind of finding that ends a security review badly.
The MCP spec made OAuth 2.1 with PKCE mandatory for HTTP transport in the 2025-03-26 revision; the 2025-06-18 revision refines the surrounding model (Protected Resource Metadata, the resource parameter, the resource-server / authorization-server split) but PKCE itself has been required for every client since 2025-03-26. PKCE (Proof Key for Code Exchange) was originally designed for mobile apps that could not safely hold a client secret, and it turns out to be exactly the right fit for MCP clients running locally on a user's laptop.
The dance, in eight steps:
code_verifier (43-128 chars, base64url).code_challenge = SHA256(code_verifier) (base64url, no padding)./authorize endpoint, passing code_challenge, code_challenge_method=S256, the redirect_uri (typically http://localhost:RANDOM_PORT/callback), and a state value.code and state./token with the code, the original code_verifier, and the redirect_uri.base64url(SHA256(code_verifier)) === code_challenge, returns an access token (and optionally a refresh token).Authorization: Bearer ... for all subsequent MCP requests.The key property: no client secret. The PKCE challenge ties the redirect to the original request without requiring the client to hold a long-lived shared secret. This is what makes it safe to run on a user's laptop where the binary is, in some sense, untrusted.
Pitfall: the redirect URI must be
http://localhost:<port>with an unpredictable port, and your client must listen on exactly that port. If your code useshttp://127.0.0.1and the auth server registeredhttp://localhost, the redirect fails with an error that says "redirect_uri_mismatch" and points at neither URL specifically. Pick one form, register it, use it. I have spent embarrassing amounts of time on this.
Access tokens expire. Refresh tokens last longer (typically days to weeks) and let the server mint new access tokens without re-prompting the user. The standard pattern is:
async function callUpstream<T>(session: Session, fn: (token: string) => Promise<T>): Promise<T> {
try {
return await fn(session.accessToken);
} catch (err) {
if (!isUnauthorized(err)) throw err;
if (!session.refreshToken) throw err;
const refreshed = await refreshAccessToken(session.refreshToken);
session.accessToken = refreshed.accessToken;
session.expiresAt = Date.now() + refreshed.expiresInSeconds * 1000;
if (refreshed.refreshToken) session.refreshToken = refreshed.refreshToken;
await persistSession(session);
return await fn(session.accessToken);
}
}
This pattern -- try, catch 401, refresh, retry once -- is the right shape. Two things to get right:
Retry exactly once. If the second call also returns 401, the refresh token is likely revoked or the user's permissions changed. Retrying again loops; bubbling the error tells the user to re-auth.
Persist the new tokens before the retry. If the retry succeeds and you do not persist, the next request hits the same expiry and refreshes again. If both refreshes happen concurrently (because the user fired two tool calls), the auth server may invalidate the first refresh token because of single-use refresh policies, and now you have a wedged session that requires re-auth even though everything was working a moment ago.
For HTTP MCP servers I run behind Fly.io or Yaw MCP, sessions get persisted to a small Postgres or SQLite store keyed by session ID. The token never leaves that store except for the duration of one request. The MCP client only ever sees opaque session IDs. This is sometimes called a "BFF" (backend for frontend) pattern; for our purposes it is just "do not put refresh tokens in JWTs."
Every credential should do the least possible. This is true everywhere in security and especially true in MCP, because MCP servers are, by design, automated agents that take actions on behalf of users. A scoped credential is the difference between "the agent helpfully closed the wrong issue" and "the agent helpfully deleted production."
Three concrete examples from servers I maintain.
@yawlabs/npmjs-mcp does not need access to your GitHub repos. It needs to talk to npm. But the metadata-mirror tools that read deprecation messages and download counts use unauthenticated GitHub API calls for some lookups, and those get rate-limited. So the optional GITHUB_TOKEN is documented as needing exactly one scope: public_repo (read-only). Not repo. Not admin:org. public_repo.
Documenting the scope precisely matters because GitHub PATs are sticky -- users create them once and forget what scopes they granted. If your README says "needs a GitHub token," users will reach for the closest token they have, which is probably the one with repo and workflow and admin:org from a prior project. That is a thirty-thousand-dollar incident waiting to happen. If your README says "needs a GitHub PAT with the public_repo scope only," users will create a new token, and the new token will be safe.
@yawlabs/tailscale-mcp actually has two credentials, and they do different jobs. The split matters because it is the cleanest example of "least privilege" in the @yawlabs portfolio.
The first credential is what the server uses to call Tailscale's Admin API (list devices, expire keys, update ACLs). That is a Tailscale OAuth client, configured via TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, with the client's grants scoped to the specific operations the server needs. We mint a short-lived bearer token at startup, refresh it before expiry, and use it for every call. Chapter 11 walks through the wiring in detail.
The second credential exists only when the server is deployed as a long-lived HTTP service on a Fly.io machine that itself joins the user's tailnet (so the tailnet's ACLs can gate which clients reach it). The key it uses to join is not a personal user key. It is a Tailscale auth key with two properties:
tag:mcp-server), and that tag's ACLs only allow the server to reach the specific endpoints it needs.The ACL is the load-bearing piece. A tagged key with a permissive ACL is identical to an untagged key with extra steps. Spend the time to write the ACL.
For the stdio version that runs on a developer's laptop, only the first credential applies; there is no tailnet-join step, because the user's laptop is already on their tailnet.
@yawlabs/aws-mcp running on a developer laptop uses the user's AWS SSO session, which is correct. @yawlabs/aws-mcp running as an HTTP service on EC2 (when I eventually publish it) will use an IAM instance role, which is also correct. The wrong answer in both cases is to bake an access key into the server's environment and pretend it is fine because it is "just a read-only key." Read-only keys for one service tend to grow into read-write keys for adjacent services, and IAM access keys do not rotate themselves. Roles do.
From the field: in a previous role I inherited a service that had an AWS access key in its env config, "just for reads." Six months later that key had
s3:PutObjecton the prod artifact bucket because someone needed to fix a deploy script "temporarily." Two years later the key was still there, and so was the developer's laptop, and the developer had left the company. The key was rotated four times and never to a role. Use roles.
Tokens fail. They are missing, expired, scope-insufficient, revoked, or simply wrong. The difference between an auth bug that takes thirty seconds to fix and an auth bug that ruins someone's afternoon is almost entirely the quality of the error message.
The good error message has three parts:
// Module scope: this runs BEFORE server.connect(transport), so a thrown
// McpError would never reach the model -- the process would just die and
// the client would see a transport-closed error with no message. Write to
// stderr (which most hosts capture and surface in their logs) and exit.
function requireToken(name: string, docsUrl: string): string {
const value = process.env[name];
if (!value) {
process.stderr.write(
[
`Missing required credential: ${name}`,
`Set it via: claude mcp add <server> -e ${name}=<value> -- npx -y <package>`,
`Docs: ${docsUrl}`,
"",
].join("\n")
);
process.exit(1);
}
return value;
}
const lsApiKey = requireToken(
"LEMONSQUEEZY_API_KEY",
"https://docs.lemonsqueezy.com/help/getting-started/api"
);
For scope-insufficient errors, parse the upstream error and translate it. If GitHub says "Resource not accessible by integration," your error should say "GitHub token missing the public_repo scope. Regenerate at https://github.com/settings/tokens with public_repo checked, then update via claude mcp add ...." The user does not know what "Resource not accessible by integration" means. They know what their token looks like.
The shape that works inside a handler-callable code path is to return a discriminated union, not to throw. A throw inside a handler bubbles up as a JSON-RPC error, which most hosts route to a developer log rather than to the model -- so the model often cannot see the carefully written message at all, and gives up or retries blindly. A returned tool result with isError: true lands in the model's context the same way a successful result does. Chapter 7 goes deep on the why; for this chapter, just adopt the pattern.
type ToolErrorResult = {
isError: true;
content: [{ type: "text"; text: string }];
};
function toolError(text: string): ToolErrorResult {
return { isError: true, content: [{ type: "text", text }] };
}
async function callGitHub<T>(
token: string,
path: string,
): Promise<{ ok: true; value: T } | { ok: false; error: ToolErrorResult }> {
const res = await fetch(`https://api.github.com${path}`, {
headers: { authorization: `token ${token}` },
});
if (res.status === 401) {
return {
ok: false,
error: toolError(
"GitHub authentication failed: GITHUB_TOKEN is invalid or expired. " +
"Do not retry -- this requires a configuration fix. The user needs " +
"to generate a new PAT at https://github.com/settings/tokens with " +
"the `public_repo` scope and update via " +
"`claude mcp add npmjs-mcp -e GITHUB_TOKEN=...`.",
),
};
}
if (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0") {
const reset = Number(res.headers.get("x-ratelimit-reset")) * 1000;
const secondsUntilReset = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
return {
ok: false,
error: toolError(
`GitHub rate limit exceeded. Resets at ${new Date(reset).toISOString()} ` +
`(in ~${secondsUntilReset}s). This is transient -- wait ${secondsUntilReset} ` +
`seconds and retry the same call. If this happens often, set GITHUB_TOKEN ` +
`to raise the per-hour limit.`,
),
};
}
if (!res.ok) {
return {
ok: false,
error: toolError(
`GitHub API returned HTTP ${res.status} for ${path}. This is usually ` +
`transient -- retry once. If it persists, the upstream may be down.`,
),
};
}
return { ok: true, value: (await res.json()) as T };
}
Pitfall: do not log the token in the error message, even partially. "Token starts with ghp_..." sounds harmless and is the kind of thing that ends up in a customer's bug report screenshot. The user knows what their token starts with. They do not need you to echo it.
A handler consumes the union by short-circuiting on the error variant and otherwise unwrapping the value:
const githubToken = requireToken("GITHUB_TOKEN", "https://github.com/settings/tokens");
server.registerTool(
"get_repo",
{
description: "Get a single GitHub repository by owner and name.",
inputSchema: { owner: z.string(), repo: z.string() },
},
async ({ owner, repo }) => {
const result = await callGitHub<{ full_name: string; description: string }>(
githubToken,
`/repos/${owner}/${repo}`,
);
if (!result.ok) return result.error;
return { content: [{ type: "text", text: JSON.stringify(result.value) }] };
},
);
Every error path returns rather than throws. Every message names what failed, the cause when known, and the next action -- whether that's "Do not retry" for a configuration problem or "wait N seconds and retry the same call" for a transient one. Chapter 7 generalizes this into a callUpstream helper that handles timeouts, network errors, and 5xx responses uniformly; for now the point is the shape, not the exhaustive coverage.
Recall the cold open: npm publish returns EOTP after a fresh WebAuthn login, retries on its own minutes later. The error message blames a missing OTP. The actual problem was session propagation.
This pattern -- credential is fine, error message blames the credential, retry succeeds -- is the npmrc class of bug. It shows up in:
InvalidClientTokenId or ExpiredToken for thirty seconds before clearing).GITHUB_TOKEN is fine but the workflow has not been granted id-token: write and the OIDC exchange returns 401.The signature: identical inputs, deterministic-looking failure, then quiet success. The fix in your MCP server is not to retry blindly. It is to:
EOTP from npm, ExpiredToken immediately after STS, etc.).async function publishWithRetry(pkg: string, attempts = 3): Promise<void> {
for (let i = 0; i < attempts; i++) {
try {
await runNpmPublish(pkg);
return;
} catch (err) {
const isPropagation = isEotpAfterFreshLogin(err);
if (!isPropagation || i === attempts - 1) throw err;
await sleep(30_000);
}
}
}
The key is that the retry logic is narrow. We retry EOTP errors only when we have evidence of a recent successful login. We do not retry every npm error -- a real EOTP from a misconfigured account would look identical to the propagation case, and retrying just delays the real fix. Narrow detection is what separates "robust to a known timing bug" from "ignores all errors."
The fastest way for a secret to leave your MCP server is via a log line. Not via a network call. Not via a malicious tool. Via console.log({ request }) printing the request body, which contained a token, into the server's stdout, which got captured by the MCP client and written to a transcript file the user later attached to a bug report.
The defenses, in order of importance:
Mark sensitive fields with a brand or wrapper type and refuse to print them:
type Secret = { readonly __secret: true; readonly value: string };
const secret = (s: string): Secret => ({ __secret: true, value: s });
// Custom toJSON for any object containing Secret fields
function redact<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj, (_k, v) => {
if (v && typeof v === "object" && v.__secret) return "[REDACTED]";
return v;
}));
}
This does not stop a developer from writing console.log(secret.value), but it does stop the much more common console.log({ session }) from accidentally dumping a token because session.accessToken is a Secret, not a string.
Every log call goes through one logger. The logger applies a redaction filter on the way out. The filter knows about common patterns: Authorization: Bearer ..., ?api_key=... query strings, ghp_* and npm_* and sk_live_* token prefixes.
const TOKEN_PATTERNS = [
/Bearer\s+[A-Za-z0-9._\-+/=]{20,}/g,
/ghp_[A-Za-z0-9]{36,}/g,
/npm_[A-Za-z0-9]{36,}/g,
/sk_live_[A-Za-z0-9]{20,}/g,
/AKIA[0-9A-Z]{16}/g, // AWS access key id
/(?<="aws_secret_access_key"\s*:\s*")[^"]+/g, // AWS secret in JSON
];
function redactString(s: string): string {
return TOKEN_PATTERNS.reduce((acc, re) => acc.replace(re, "[REDACTED]"), s);
}
function log(level: string, msg: string, meta?: object) {
const safe = meta ? redactString(JSON.stringify(meta)) : "";
process.stderr.write(`${level}: ${redactString(msg)} ${safe}\n`);
}
Use process.stderr for stdio servers. process.stdout is the JSON-RPC channel and writing log lines there will desync your client.
The most insidious leak is the stack trace from a fetch error that includes the request URL with an embedded query-string token. Node's default error formatting is fine; many HTTP libraries add the URL to the error message. Audit your dependencies:
process.on("uncaughtException", (err) => {
log("error", "uncaught", { stack: redactString(err.stack ?? String(err)) });
process.exit(1);
});
Pitfall: structured loggers (pino, winston, bunyan) serialize objects deeply and, by default, will happily emit a token field. Most have a
redactoption. Use it. But also note that pino'sredactonly matches exact paths -- if your token shows up under a path you forgot to list, it leaks. Combine path-based redaction with regex-based output filtering.
The single most common architectural mistake in MCP servers built for an audience of more than one is the global token. Someone writes a Slack MCP server, ships it to an internal team, and uses one Slack bot token to serve all twenty engineers. From Slack's perspective every action is taken by the bot; the audit log is useless; permissions are uniform; if one engineer can do something, everyone can.
If your server serves many users, every tool call must execute with credentials specific to that user. Period.
The mechanics, depending on transport:
Stdio servers are inherently single-tenant. Each user spawns their own copy. There is no multi-tenancy story to get wrong. This is a feature, and a strong reason to ship stdio servers when you can.
The session pattern from earlier in this chapter is the answer. Every request carries a user-bound token; the server never has access to a credential that is not bound to a session.
Concretely:
{ session_id, user_id, upstream_token_encrypted, refresh_token_encrypted, expires_at }.// The wrong shape for a multi-tenant HTTP MCP server
const GITHUB_TOKEN = process.env.GITHUB_TOKEN; // <-- nope
server.registerTool(
"gh_list_repos",
{ description: "List repos.", inputSchema: {} },
async () => {
const octokit = new Octokit({ auth: GITHUB_TOKEN }); // <-- everyone is the same user
// ...
},
);
// The right shape: a per-session server whose handlers close over the session token
function buildServerForSession(session: Session) {
const server = new McpServer({ name: "github-tools-mcp", version: "1.0.0" });
server.registerTool(
"gh_list_repos",
{ description: "List repos.", inputSchema: {} },
async () => {
const token = await decryptToken(session.upstream_token_encrypted);
const octokit = new Octokit({ auth: token });
// ...
},
);
return server;
}
If you find yourself wanting to "just use a global token for the metadata calls," stop. There are no metadata calls. Every call is a user call. If you really need an unauthenticated read, use the unauthenticated endpoint. If the unauthenticated endpoint does not exist, the call is a user call.
A side effect of per-user credentials is per-user rate limits. GitHub will rate limit each user separately at 5,000 req/hour, where a global token would be capped at 5,000 req/hour for everyone. This is almost always the right outcome -- a user who wedges their own quota is a user-specific problem, not a fleet-wide outage. Build your error handling so a 429 from GitHub for one session does not affect the others.
Stateful servers add a second axis to multi-tenancy: per-tenant memory and stored context, on top of per-tenant credentials. Chapter 10's "Multi-tenant memory in stateful MCP servers" sidebar covers that surface.
claude mcp add Env Passthrough PatternFor stdio servers, claude mcp add with -e flags is the canonical way to plumb credentials. Document it in your README, exactly as you want users to type it, copy-pasteable.
# In the @yawlabs/lemonsqueezy-mcp README
claude mcp add lemonsqueezy-mcp \
-e LEMONSQUEEZY_API_KEY=YOUR_KEY_HERE \
-e LEMONSQUEEZY_STORE_ID=YOUR_STORE_ID \
-- npx -y @yawlabs/lemonsqueezy-mcp
The -e flag stuffs the variable into the spawn config. This has three nice properties:
GITHUB_TOKEN in their shell for another tool, your server can have its own.What goes into the readme is also what goes into your error message when the credential is missing. Same exact claude mcp add line. Copy-paste, fill in the blank, restart, done. The single biggest UX improvement you can make in an MCP server is making the "I forgot to set this" error tell the user exactly how to set it.
// Same pattern as requireToken above. Read process.env at call time, not module load,
// so a test harness or dynamic config can populate the variable after import.
// Boot anyway; surface the missing-key error at first call (see the parting trick below).
function requireApiKey(): string {
const apiKey = process.env.LEMONSQUEEZY_API_KEY;
if (!apiKey) {
throw new Error(
"LEMONSQUEEZY_API_KEY is not set. Configure with:\n\n" +
" claude mcp add lemonsqueezy-mcp \\\n" +
" -e LEMONSQUEEZY_API_KEY=YOUR_KEY \\\n" +
" -- npx -y @yawlabs/lemonsqueezy-mcp\n\n" +
"Get your key at https://app.lemonsqueezy.com/settings/api"
);
}
return apiKey;
}
A user hitting this error has everything they need: the variable name, the command to set it, and the URL to get the value. Three minutes from error to fix, no Googling.
The patterns from this chapter, condensed:
Stdio server, single tool: door one (env vars). Document with claude mcp add -e .... Fail loudly with a one-line fix when missing.
Stdio server wrapping a tool with a credential chain (AWS, GitHub CLI, gcloud): door three -- inherit. Do not reimplement the chain. Fall back to door one only for the "I want to override the default" case.
Stdio server wrapping a backend without a credential chain (LemonSqueezy, npm, the Tailscale Admin API): door one (env vars), with the credential scoped as narrowly as the upstream allows. Tailscale's case is worth naming: the API auth is an OAuth client (TAILSCALE_OAUTH_CLIENT_ID + TAILSCALE_OAUTH_CLIENT_SECRET) read from env, not the local tailscaled socket.
HTTP server, public: OAuth 2.1 + PKCE, sessions in a server-side store, per-session credentials, rotate access tokens with the try/refresh/retry pattern, never a global upstream credential.
HTTP server, internal (one company, SSO already in place): OIDC against the company IdP, tokens minted per-user, same per-session credential pattern. Do not invent your own auth. The spec-level version of this shape is MCP's Enterprise-Managed Authorization (EMA), which lets one SSO login authorize every remote MCP server without per-server consent screens.
CI publishing: automation tokens, never local session tokens. The npm story (don't copy ~/.npmrc) is the canonical example. AWS, GitHub Container Registry, Docker Hub all have analogous distinctions.
Error messages: what is wrong, what to set, where the docs are. Three sentences max. Copy-pasteable command if applicable.
Logging: redact at the boundary, brand sensitive fields at the type level, write to stderr from stdio servers, audit your dependencies for token leakage.
Multi-tenancy: zero global upstream credentials in HTTP servers. Every call is a user call. If you cannot identify the user for a call, refuse the call.
This chapter has been about getting the right credential to the right call. The next chapter is about the call itself -- specifically, the schema that describes its inputs and the descriptions that tell the model when and how to use it. Auth and schemas are the two leakiest surfaces in production servers, and they leak together: a tool with a great auth model and a vague schema gets called wrong by the model and fails for reasons that look like auth bugs. The work we did here -- precise error messages that name what to fix -- will pay off again in Chapter 5 as we shape the descriptions that prevent those bad calls in the first place.
For now, two things to internalize:
The first is that secrets in MCP servers are not a separate concern from the rest of the server's design. The credential model determines the transport, the multi-tenancy story, the error UX, and the failure modes. Decide where credentials enter (the three doors) before you write your first tool handler, because retrofitting an auth model is one of the most expensive refactors in this stack.
The second is that the npmrc class of bug is real, it will hit you, and the right response is narrow detection plus a precise retry, not a blanket retry-on-everything loop. Auth backends propagate. Sessions propagate. STS clocks skew. Your job is to know which specific propagation you are watching for, retry that specific case, and let everything else fail loudly and clearly.
If you remember nothing else from this chapter: every credential should do the least possible, every error message should tell the user exactly how to fix it, and every HTTP MCP server should treat "global upstream token" as a typo for "audit log fire." Get those three things right and you will have skipped the worst of the auth pain that has eaten my afternoons.
The credential is the easy part. The error message is the product.
One thirty-line helper that sits at the front of every auth-bearing @yawlabs server: a token classifier that logs (to stderr) which kind of credential the server booted with, and warns when the prefix doesn't match any known shape. It is not a security boundary -- a forged prefix will not fail the upstream call, only an actual auth check will -- but it is a "tell the user something useful when the wrong token is in the wrong env var" affordance, and it has saved me support tickets every time a user has confidently pasted an OAuth token where a fine-grained PAT belonged.
function classifyGithubToken(token: string): string {
if (token.startsWith("ghp_")) return "classic-pat";
if (token.startsWith("github_pat_")) return "fine-grained-pat";
if (token.startsWith("gho_")) return "oauth";
if (token.startsWith("ghs_")) return "installation";
return "unknown";
}
const raw = process.env.GITHUB_TOKEN?.trim();
if (raw) {
const kind = classifyGithubToken(raw);
if (kind === "unknown") {
process.stderr.write(
`[github-tools-mcp] GITHUB_TOKEN is set but the prefix is unfamiliar. ` +
`Expected one of: ghp_, github_pat_, gho_, ghs_. ` +
`Continuing anyway -- GitHub may have added a new prefix.\n`,
);
} else {
process.stderr.write(`[github-tools-mcp] Auth loaded (token type: ${kind}).\n`);
}
} else {
process.stderr.write(
`[github-tools-mcp] No GITHUB_TOKEN set; authenticated tools will return errors.\n`,
);
}
Three things this earns. First, the boot log tells the user whether their token is the kind they thought it was; if they meant to paste a fine-grained PAT and pasted a classic by accident, the line above tells them. Second, anonymous is treated as a valid state -- the server boots, the unauthenticated tools work, the authenticated ones return a clear "missing token" error when called. Most stdio servers I've audited fail this last step: they refuse to boot at all if a token is missing, which means the user can't get any value from the server until they configure auth, which means the install-to-first-success time has an extra step glued onto the front. Boot anyway. Let the per-tool requireToken raise the missing-credential error at the moment of need. Third, the warning is non-fatal -- GitHub may add a new prefix tomorrow and we don't want to be the reason somebody can't use their valid token. A warning that names what we expected is the right balance between "useful diagnostic" and "false-positive hostile."
You will not notice the difference on the day you ship. You will notice it the first time a user emails saying "the server doesn't work" and the answer is in their own log line.
Take the server from Chapter 3 and add real authentication. Two new tools (create_issue and list_my_repos are the canonical pair, but anything that requires repo scope is fine), a token classifier at startup, the full error-message-as-recovery-path discipline (every status code gets a specific actionable message), and a README that names exactly which scopes each tool needs. Bump to 0.2.0 and republish.
The bar to clear: the unauthenticated tools from Chapter 3 still work without a token (a user who skips auth still gets value), the authenticated tools return a clear and actionable error when called without a token (no opaque -32603 Internal error), and the token never appears in any error message or log line you emit -- grep for the env-var name in your error paths before you ship.
Solution and starter code at https://github.com/YawLabs/mcp-in-production-companion, tag module-4-final. The companion repo is public -- just clone it.
Only for HTTP transport. The 2025-03-26 spec revision made OAuth 2.1 with PKCE mandatory for every HTTP-transport MCP client, and the 2025-06-18 revision refined the surrounding model (Protected Resource Metadata, the resource parameter, the resource-server / authorization-server split). Stdio servers do not use OAuth at all: they run as a subprocess with the user's own environment, so they read credentials from env vars or inherit the wrapped tool's existing credential chain.
For stdio servers, use your client's env passthrough: claude mcp add <server> -e MYTOOL_API_KEY=... -- npx -y <package> stuffs the variable into the spawn config. Setting it in your shell rc is unreliable, because GUI-launched clients do not inherit the shell's environment. The -e flag scopes the credential to that one server, needs no shell rc edits, and keeps one source of truth in the client's config file. For HTTP servers, credentials are per-session bearer tokens -- never a global key shared across users.