Design Patterns and Production: Auth, Idempotency, Retries, Observability, Testing, Cost
Learning Objectives
Secure a tool server with authentication and authorization appropriate to its transport, and defend against injection and confused-deputy attacks.
Make tool calls safe to retry using idempotency keys and well-chosen retry/backoff policies, and handle rate limits correctly.
Instrument an agent with tracing, logging, and evals so failures are observable and testable.
Reason quantitatively about the cost and latency of an agentic system and optimize both.
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
Transport determines the auth mechanism: remote transports (HTTP/SSE) SHOULD use OAuth 2.1 + PKCE; local STDIO SHOULD NOT run a browser flow and instead reads credentials from the environment.
PKCE everywhere, audience validation always: PKCE binds the auth code to the requesting client; the aud claim (plus RFC 8707 Resource Indicators) stops confused-deputy token passthrough. Reject generic audiences.
Least privilege via per-tool scopes: a read tool requires a read scope, a write tool a write scope, verified per route on the resource server.
Injection defense is layered: schema-validate every tool input (Zod/Pydantic) and never let untrusted content flow into a privileged action ungated.
Sandbox + human-in-the-loop for irreversible actions: isolate execution with allowlists, and gate hard-to-reverse actions (email, delete, payment) behind explicit approval — reversibility is the criterion.
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
Transport
Deployment
Recommended auth
Credential source
STDIO
Local child process
No OAuth flow
Environment variables / embedded libraries
HTTP / SSE / Streamable HTTP
Remote server
OAuth 2.1 + PKCE
Access token via authorization flow
Direct Claude API tool
Your application code
API key (x-api-key) or OAuth profile
Secret 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.
Threat
What the attacker does
Structural defense
Prompt injection
Smuggles instructions into content the agent reads
Validate aud against server URL; reject generic audiences (RFC 8707)
Credential leakage
Reads secrets from logs or source
Secret manager; never log Authorization headers; short-lived tokens
Over-broad tool access
Uses a low-privilege token for a high-privilege tool
Per-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
Duplicate calls have two sources: infrastructure retries (the SDK retries 429/5xx/connection errors twice by default) and model-level duplication (the model re-emits the same tool_use). You cannot stop the asking — make it harmless.
The Messages API has no idempotency-key header; build idempotency at the tool layer with stable client-side keys, atomic upsert, and request-id correlation.
Keys must be stable, dedup must be atomic: derive the key deterministically from inputs and implement dedup as one atomic INSERT ... ON CONFLICT, or concurrent retries duplicate the side effect.
Rely on the SDK's backoff + jitter; tune max_retries and timeout rather than reimplementing the loop. Retry 429 and ≥500; never retry other 4xx.
429 vs 529: honor retry-after exactly on a 429 (your account's limit); a 529 is a system-wide overload and should not count against your own accounting.
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
Code
error.type
Meaning
Retryable?
Handling
400
invalid_request_error
Malformed request
No
Fix the request
401
authentication_error
API-key problem
No
Fix credentials
403
permission_error
Key lacks permission
No
Fix permissions
404
not_found_error
Resource not found
No
Fix model ID / endpoint
429
rate_limit_error
Your account hit a limit
Yes
Honor retry-after exactly
500
api_error
Internal error
Yes
Exponential backoff
529
overloaded_error
System-wide overload
Yes
Backoff; 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
Three OTel signals capture the agent loop: metrics (tokens, cost, sessions), log events (prompts, requests, errors), and traces (nested spans for interaction, llm_request, tool, hook). Each has its own enable switch.
Configuration, not code: the Claude Code CLI has OTel instrumentation built in and exports to any OTLP backend; you set environment variables. Telemetry is off until enabled.
Privacy is the default: telemetry is structural (durations, model/tool names, token counts); prompt and tool content are opt-in. Avoid the console exporter — it corrupts the SDK's stdout channel.
Evals are built on the same telemetry: capture a transcript once and assert deterministically on tool_use blocks — no live side effects, because the API is stateless and transcripts are fully replayable.
Monitor three things continuously: failure rates, cost spikes (including the cache rate), and behavioral drift — the drift being the failure mode with no exception to catch.
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.
Signal
Contains
Enable with
Metrics
Counters for tokens, cost, sessions, tool decisions
OTEL_METRICS_EXPORTER
Log events
Structured records for each prompt, API request, API error, tool result
OTEL_LOGS_EXPORTER
Traces
Spans for each interaction, model request, tool call, and hook (beta)
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
Account across four categories: input, output, cache-write, cache-read. Total prompt size = input_tokens + cache_creation_input_tokens + cache_read_input_tokens — never read input_tokens alone.
Prompt caching is the dominant lever: reads cost ~0.1× base input (a routine ~78% saving on a long conversation) and don't count toward ITPM; writes cost 1.25× (5-min TTL) or 2.0× (1-hour TTL), breaking even in 2–3 reads.
Cache is a prefix match: cache stable content (system, tools, context, history); put volatile content after the last breakpoint. Any byte change in the prefix invalidates everything after it.
Other levers: cheapest-sufficient model selection, the Message Batches API (50% price, async), and max_tokens generously (no OTPM downside).
Latency is dominated by round-trips: parallelize independent tool calls into one message and compose calls into a script; stream for perceived speed and for large outputs.
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 (tools → system → messages), 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.
Operation
Cost (× base input)
Notes
Cache read
0.1× (~90% saving)
Also cuts latency — the prefix isn't reprocessed
Cache write, 5-minute TTL
1.25×
Break-even at ~2 reads
Cache write, 1-hour TTL
2.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?