Design Patterns and Production: Auth, Idempotency, Retries, Observability, Testing, Cost

Learning Objectives

Pre-Reading Check — Auth and Safety for Tool Servers

1. A remote MCP server (HTTP transport) needs to authenticate callers. What mechanism does the spec recommend?

2. Why must a resource server validate the aud (audience) claim on an incoming token?

3. What principle should govern the scopes granted to each tool?

4. What is the decisive criterion for gating an action behind human-in-the-loop approval?

5. The best structural defense against prompt injection through fetched content is to:

1. Auth and Safety for Tool Servers

Key Points

An agent is a deputy: it acts on behalf of a user, wielding tools that touch real systems. Four defensive layers are needed — authenticating callers, authorizing them with least privilege, defending against injection and confused-deputy risks, and sandboxing dangerous actions behind human review.

Authentication establishes who a caller is, and the transport dictates the mechanism. MCP follows OAuth 2.1 conventions where they apply but does not mandate a single identity system.

Authentication mechanisms by transport

TransportDeploymentRecommended authCredential source
STDIOLocal child processNo OAuth flowEnvironment variables / embedded libraries
HTTP / SSE / Streamable HTTPRemote serverOAuth 2.1 + PKCEAccess token via authorization flow
Direct Claude API toolYour application codeAPI key (x-api-key) or OAuth profileSecret manager / env var

For direct Claude API integrations the caller is your own application, which authenticates to Anthropic with an API key or OAuth profile — a trust domain entirely separate from how end users authenticate to your tools.

The OAuth 2.1 + PKCE handshake for remote MCP servers

An unauthenticated client gets a 401 with a WWW-Authenticate header pointing to a Protected Resource Metadata document; the client discovers the authorization server, registers (statically or via DCR), completes an authorization-code grant with PKCE, and thereafter sends Authorization: Bearer <token> on every request. Two hard rules make this secure: PKCE applied universally (defeats code interception) and audience validation — the server must confirm the token's aud claim was minted for this server and reject generic audiences like api.

sequenceDiagram participant C as MCP Client participant M as MCP Server (Resource Server) participant A as Authorization Server C->>M: Connect (no token) M-->>C: "401 Unauthorized" + WWW-Authenticate C->>M: GET .well-known/oauth-protected-resource M-->>C: Metadata (RFC 9728): auth servers + scopes C->>A: GET auth-server metadata (RFC 8414) A-->>C: authorization / token / registration endpoints C->>A: Register client (DCR, RFC 7591) C->>A: "/authorize" + code_challenge (PKCE) A-->>C: User logs in and consents; auth code C->>A: Exchange code + code_verifier for tokens A-->>C: Access token (with "aud" claim) C->>M: Request + "Authorization: Bearer token" M->>M: Validate signature, expiry, issuer, audience, scope M-->>C: Tool result
[Animation slot: step-through of the 401 challenge → metadata discovery → PKCE code exchange → audience check, highlighting the token's aud claim being matched against the server URL]

Authorization, injection, and confused deputy

Authorization answers what an authenticated caller may do, governed by least privilege and enforced with per-tool scopes checked on each route. Key hygiene: never hand-roll token validation, use short-lived tokens, enforce HTTPS, never log credentials, control Dynamic Client Registration, pin to a single issuer, and treat Mcp-Session-Id as untrusted input.

Even a correctly authenticated agent can be tricked. Prompt injection is the tool-server analog of SQL injection: untrusted content can smuggle instructions to hijack the agent. The confused deputy problem is the token-passthrough attack the audience check defends against — a component with more privilege than the requester blindly forwarding a token issued for another service.

ThreatWhat the attacker doesStructural defense
Prompt injectionSmuggles instructions into content the agent readsSchema-validate inputs; gate privileged actions; distrust fetched content
Confused deputy / token passthroughReplays a token issued for another serviceValidate aud against server URL; reject generic audiences (RFC 8707)
Credential leakageReads secrets from logs or sourceSecret manager; never log Authorization headers; short-lived tokens
Over-broad tool accessUses a low-privilege token for a high-privilege toolPer-tool scopes; least privilege; verify scope per route

Sandboxing isolates tool execution with containers and executable allowlists (never blocklists). Human-in-the-loop (HITL) gates hard-to-reverse actions behind explicit approval, with reversibility as the criterion. Promoting a dangerous action from an opaque bash string to a dedicated typed tool (send_email(to, subject, body)) makes it trivially gateable — which is why you reach for the manual agentic loop to intercept each tool_use block.

Post-Reading Check — Auth and Safety for Tool Servers

1. A remote MCP server (HTTP transport) needs to authenticate callers. What mechanism does the spec recommend?

2. Why must a resource server validate the aud (audience) claim on an incoming token?

3. What principle should govern the scopes granted to each tool?

4. What is the decisive criterion for gating an action behind human-in-the-loop approval?

5. The best structural defense against prompt injection through fetched content is to:

Pre-Reading Check — Idempotency and Retries

1. Where do you implement idempotency for the Claude Messages API?

2. What makes an idempotency key work across retries and model re-emissions?

3. Why must the dedup-check-and-store steps be atomic?

4. On a 429 rate_limit_error that includes a retry-after header, what should you do?

5. How does a 529 overloaded_error differ from a 429 for your accounting logic?

2. Idempotency and Retries

Key Points

A tool call can succeed on the server but time out before the client sees the result; a rate limit can bounce a good request; and the model can decide to call the same tool a second time. Both infrastructure retries and model duplication converge on one requirement: the tool's side effect must be idempotent — executing it twice has the same effect as executing it once.

Idempotency keys and deduplication

An idempotency key names a logical operation so the server can recognize a repeat and skip re-executing the side effect. Three techniques deliver it together: (1) generate a stable client-side key derived deterministically from the operation's inputs (e.g. a hash of user_id + action + target); (2) make side-effecting tools idempotent via upsert semantics so a replay is a no-op; (3) record the Anthropic request-id to correlate retried calls. The critical detail: the dedup-check-and-store must be atomic (one INSERT ... ON CONFLICT DO NOTHING on a UNIQUE key column), or two concurrent retries both pass the check and duplicate the effect.

flowchart TD A[Tool call received] --> B[Derive stable idempotency key
hash of op + params] B --> C{Key already in
durable store?} C -->|"Yes: replay"| D[Return stored result
deduplicated = true] C -->|"No: first time"| E[Execute side effect once] E --> F["Atomic upsert:
INSERT ... ON CONFLICT DO NOTHING"] F --> G[Return fresh result
deduplicated = false] D --> H[Response to caller] G --> H

For work that must not duplicate and may run long, reach for the Message Batches API (submit and poll, sidestepping the mid-request-drop failure mode) and use streaming for requests over ~10 minutes.

Retry, exponential backoff, and jitter

Exponential backoff increases the wait geometrically (1s, 2s, 4s, 8s…) to give an overloaded system time to recover; jitter adds a random offset to prevent the "thundering herd" of synchronized retries. The single most important fact: the official SDKs already implement backoff and jitter correctly — tune max_retries and timeout rather than reimplementing. When you must roll a custom loop for a third-party API, catch the most specific retryable classes first (429, ≥500, connection errors) and never retry other 4xx.

flowchart TD A[Execute request] --> B{Outcome?} B -->|Success| C[Return result] B -->|"429: rate limited"| D{"Retry-After
header present?"} D -->|Yes| E[Sleep exactly Retry-After] D -->|No| F[Exponential backoff + jitter] B -->|"529: overloaded"| F B -->|">= 500: server error"| F B -->|"Connection error"| F B -->|"Other 4xx"| G[Do NOT retry — raise] E --> H{Attempts left?} F --> H H -->|Yes| A H -->|No| I[Raise last exception]
[Animation slot: side-by-side timeline of retries without jitter (synchronized thundering herd spikes) vs with jitter (spread out), showing the load-relief effect]

Handling rate limits: 429, 529, and Retry-After

Codeerror.typeMeaningRetryable?Handling
400invalid_request_errorMalformed requestNoFix the request
401authentication_errorAPI-key problemNoFix credentials
403permission_errorKey lacks permissionNoFix permissions
404not_found_errorResource not foundNoFix model ID / endpoint
429rate_limit_errorYour account hit a limitYesHonor retry-after exactly
500api_errorInternal errorYesExponential backoff
529overloaded_errorSystem-wide overloadYesBackoff; don't count against your accounting

On a 429, check retry-after first and wait exactly that duration. Better still, steer client-side throttling before you hit a 429 by watching the anthropic-ratelimit-*-remaining headers. A 529 is a system-wide overload — back off, but do not count it against your own limit accounting. Limits are enforced with a token-bucket algorithm, so a burst of 60 simultaneous requests can trip a "60/minute" limit; pace requests and ramp new traffic gradually.

Post-Reading Check — Idempotency and Retries

1. Where do you implement idempotency for the Claude Messages API?

2. What makes an idempotency key work across retries and model re-emissions?

3. Why must the dedup-check-and-store steps be atomic?

4. On a 429 rate_limit_error that includes a retry-after header, what should you do?

5. How does a 529 overloaded_error differ from a 429 for your accounting logic?

Pre-Reading Check — Observability and Testing

1. What are the three OpenTelemetry signals used for Claude agent observability?

2. By default, what does Claude agent telemetry record?

3. Why should you avoid the console exporter with the Agent SDK?

4. What is the most powerful, deterministic way to test agent behavior without live side effects?

5. Which failure mode has no exception to catch, making continuous evals essential?

3. Observability and Testing

Key Points

Observability is the property that a system's internal state can be inferred from its external outputs. For a Claude agent it rests on three OpenTelemetry signals, each with its own enable switch.

SignalContainsEnable with
MetricsCounters for tokens, cost, sessions, tool decisionsOTEL_METRICS_EXPORTER
Log eventsStructured records for each prompt, API request, API error, tool resultOTEL_LOGS_EXPORTER
TracesSpans for each interaction, model request, tool call, and hook (beta)OTEL_TRACES_EXPORTER + CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1

With the enhanced-telemetry beta on, each step of the loop becomes a span: claude_code.interaction (one turn), claude_code.llm_request (each API call, carrying model name, latency, token counts), claude_code.tool (each invocation, with child spans for permission waits and execution), and claude_code.hook. These nest naturally, and the SDK auto-propagates W3C trace context into the CLI subprocess, so an agent run appears inside your application's trace.

flowchart LR A[Agent SDK] --> B[Claude Code CLI
OTel instrumentation] B --> M[Metrics: tokens, cost, sessions] B --> L[Log events: prompts, requests, errors] B --> T[Traces: interaction / llm_request / tool / hook spans] M --> COL[OTLP Collector] L --> COL T --> COL COL --> BK["Backend
(Honeycomb / Datadog / Grafana / Langfuse)"] BK --> D[Dashboards, alerts, eval scores]

Privacy is the default: only durations, model/tool names, and token counts are recorded; content is opt-in via OTEL_LOG_USER_PROMPTS, OTEL_LOG_TOOL_DETAILS, etc. Two gotchas: never use the console exporter (stdout is the SDK's message channel) and lower export intervals for short-lived calls so data reaches the collector before the process exits.

Evals: testing agent behavior

Traces tell you what happened; evals tell you whether it was correct. An eval is a systematic test of agent behavior — the agent analog of a unit-test suite. Build a test set (representative inputs paired with expected actions: which tools fire, in what order, with what arguments), run and capture the transcript, then score with programmatic assertions and/or LLM-as-judge. The most powerful technique is replaying transcripts and asserting on tool_use: because the API is stateless, a captured transcript is a complete, replayable artifact, so a tool-use assertion is deterministic, cheap, and side-effect-free.

[Animation slot: a captured transcript flowing into an assertion engine that checks the tool_use block name and arguments, showing pass/fail without touching live tools]

Monitoring drift, cost spikes, and failure rates

Evals catch regressions before deploy; monitoring catches them after. Three metrics deserve standing alerts: failure rates (errored vs successful calls, from log events), cost spikes (sudden token-spend increases, from metrics — watch the cache rate), and behavioral drift (the same eval suite run continuously against production; scores sliding even though nothing errored). Drift is the failure mode with no exception to catch, which is precisely why continuous evals matter. Observability and testing are one system: the traces you capture for debugging are the transcripts you replay for evals.

Post-Reading Check — Observability and Testing

1. What are the three OpenTelemetry signals used for Claude agent observability?

2. By default, what does Claude agent telemetry record?

3. Why should you avoid the console exporter with the Agent SDK?

4. What is the most powerful, deterministic way to test agent behavior without live side effects?

5. Which failure mode has no exception to catch, making continuous evals essential?

Pre-Reading Check — Cost and Latency Engineering

1. How do you compute the total prompt size from the response usage fields?

2. Roughly what does a cache read cost relative to base input?

3. Where should volatile content (timestamps, the varying user question) go relative to a cache breakpoint?

4. If cache_read_input_tokens is zero across repeated identical-prefix requests, the likely cause is:

5. What is the primary way to cut latency in an agentic loop with independent tool calls?

4. Cost and Latency Engineering

Key Points

Every token has a price and a latency. In an agentic loop that re-sends a growing transcript each turn, both compound into the bill and the wait.

Token accounting

Usage splits into distinct categories, each priced differently: input tokens (base rate, reflecting only the uncached remainder after your last cache breakpoint), cache-write tokens (a premium over base), cache-read tokens (~10% of base), and output tokens (higher base output rate; thinking counts as output). The key identity: total prompt size = input_tokens + cache_creation_input_tokens + cache_read_input_tokens — sum all three or you will misjudge cost and cache effectiveness.

Prompt caching: the biggest single lever

Prompt caching reuses previously processed prompt prefixes. Claude renders a prompt in a fixed order (toolssystemmessages), and caching is a prefix match: mark a breakpoint with cache_control: {"type": "ephemeral"} (up to four per request), and a later request with the same prefix skips reprocessing it.

OperationCost (× base input)Notes
Cache read0.1× (~90% saving)Also cuts latency — the prefix isn't reprocessed
Cache write, 5-minute TTL1.25×Break-even at ~2 reads
Cache write, 1-hour TTL2.0×Break-even at ~3 reads within the hour

The TTL clock resets on each read, so a chatty session stays warm. Cache stable parts (system instructions, context documents, tool definitions, history) and put volatile content after the last breakpoint, because any byte change in the prefix invalidates everything after it. A worked 10-turn example with a 50,000-token fixed prefix drops from $2.525 to $0.5625 — a 78% reduction — and every cached turn is also faster. Verify it works by reading cache_read_input_tokens; if it's zero, a silent invalidator (a datetime.now(), an unsorted json.dumps(), a per-request tool set) is shifting the prefix bytes.

flowchart TD A[Request arrives] --> B{Prefix bytes match
a warm cache?} B -->|"No: first turn / invalidated"| C["Cache write
(1.25x base input, 5-min TTL)"] B -->|"Yes: prefix hit"| D["Cache read
(0.1x base input, ~90% saving)"] C --> E[Process variable suffix
at base input rate] D --> F[Reset TTL clock on read] F --> E E --> G[Generate output
at output rate] G --> H["Cached-read tokens also
exempt from ITPM limit"]

Caching also raises effective throughput: for most models only uncached input counts toward the input-tokens-per-minute (ITPM) limit, so a 2M-ITPM limit at an 80% hit rate effectively processes ~10M input tokens/minute.

[Animation slot: bar chart animating the 10-turn cost comparison — $2.525 without caching vs $0.5625 with caching — and the TTL clock resetting on each read]

Other cost and latency levers

Model selection: route each task to the cheapest model that meets the quality bar; spawning a cheaper subagent preserves the main loop's model-scoped cache. Batching: the Message Batches API runs async at 50% of standard prices. max_tokens has no rate-limit downside — set it generously to avoid truncation.

Latency is dominated by round-trips. Two structural fixes: parallelize independent calls (one assistant message can contain multiple tool_use blocks; return all tool_result blocks in a single user message — splitting them trains the model to stop parallelizing), and compose calls into a script (programmatic tool calling keeps intermediate results in the code container, so only the final output re-enters context). Finally, a latency budget allocates target time per stage, and streaming wins perceived speed and is a correctness requirement for large outputs (the SDKs refuse non-streaming requests estimated to exceed ~10 minutes).

Post-Reading Check — Cost and Latency Engineering

1. How do you compute the total prompt size from the response usage fields?

2. Roughly what does a cache read cost relative to base input?

3. Where should volatile content (timestamps, the varying user question) go relative to a cache breakpoint?

4. If cache_read_input_tokens is zero across repeated identical-prefix requests, the likely cause is:

5. What is the primary way to cut latency in an agentic loop with independent tool calls?

Your Progress

Answer Explanations