Stress Testing MCP Servers Under Real Agent Workloads
Load-testing an MCP server like a REST API measures the wrong things. Model agent-like traffic - discovery bursts, chatty tool sequences, SSE subscriptions - run it with k6, and watch per-method latency, tool error rate, and first-event time.
Published on • October 17, 2026
AI Assistant

Point a REST load generator at an MCP server and you will get a number that means nothing. You will hammer tools/call at a fixed rate, watch requests per second climb, and conclude your server handles load well - right up until real agents connect and the server falls over for reasons the test never exercised.
Agent traffic does not look like REST traffic. One agent client sequences its requests: it discovers the server, lists tools, calls one tool, reads the result, calls another, maybe opens a notification stream, and repeats - with think-time gaps that depend on an LLM in the middle. Twenty agents are twenty chatty sessions, not two thousand uniform requests. And a chunk of the protocol’s cost lives in the handshake and the stream, not in the tool handler you benchmarked.
This post is about testing MCP servers the way they actually get used: what makes the workload different, how to model it, a k6 harness you can run today, the metrics worth alerting on, and the parts of a server that typically break first.
What makes MCP traffic different
A quick orientation from the protocol docs before we load anything. MCP is a client-server protocol where an MCP host - an AI application - creates one client per server, each holding a dedicated connection. The data layer is JSON-RPC 2.0; the transport layer is either stdio (local process, single client) or Streamable HTTP (HTTP POST for client-to-server messages with optional Server-Sent Events streaming, supporting standard HTTP auth including bearer tokens and API keys, with OAuth recommended for obtaining tokens). For load testing, only the Streamable HTTP transport is in scope - stdio servers are single-client local processes.
Four properties of the protocol shape the workload:
Discovery is a first-class round trip. Clients can send a server/discover request to learn supported protocol versions, capabilities, and server identity, and the response carries cache hints - a ttlMs freshness window and a cacheScope - so a well-behaved client caches it. But nothing forces a client to cache. A load model that replays the full discovery handshake per session measures your JSON-RPC dispatch and serialization, not just your tool code.
Every request is self-contained. As of protocol version 2026-07-28, MCP is described as stateless: every request carries the protocol version and the relevant capabilities in its _meta field, and clients identify themselves there too, so the server infers nothing from prior requests. That sounds like a gift for horizontal scaling, but it has a cost your load test will find: the server re-validates protocol metadata on every single call, and any per-session optimization you added - memoized auth, cached tool lists - is now your own bookkeeping to get right under concurrency.
Listings are dynamic and cacheable. tools/list returns the tool array with input schemas, and responses can carry ttlMs freshness hints. Agents re-fetch tool lists when servers send notifications/tools/list_changed - and those notifications are opt-in: the client opens a long-lived subscriptions/listen stream naming the types it wants, and delivery is explicitly best-effort across reconnects. So real traffic mixes cheap cached reads with occasional expensive full-list serializations that spike exactly when tool sets change.
Sessions are multi-request. The unit of agent load is not a request, it is a session: discover, list, then a burst of related tools/calls with LLM-paced gaps, possibly with a notification stream held open the whole time. Connection count, stream lifetime, and per-session state are first-class load dimensions - and they are invisible to a request-rate-only test.
Modeling agent-like traffic
Design the workload around session shapes, not request rates. A practical breakdown:
| Scenario | Session shape | What it stresses |
|---|---|---|
| Discovery burst | server/discover then tools/list, repeated by many new clients at once | Cold-path serialization, connection setup, TLS handshakes |
| Focused worker | One session, 5-15 sequential tools/calls on a single tool | Tool handler latency and throughput - the classic case |
| Chatty explorer | One session calling 4-8 different tools with small payloads | Dispatch, per-call validation, cache behavior across tools |
| Streamer | Session that opens subscriptions/listen, then calls tools while holding the stream | Long-lived connections, SSE write path, notification fan-out |
| Flapper | Short sessions that connect, discover, and disconnect rapidly | Connection churn, cleanup, resource leaks |
Two parameters control realism above all: VUs (virtual users) as sessions, where each VU holds its own connection and interleaves calls with think-time pauses, and think time between calls. An agent waiting on an LLM takes 1-30 seconds between tool calls; a test with zero think time models an agent that never stops talking, which exists but is not the norm. Give each session a scenario mix - mostly focused workers with a slice of streamers - and the aggregate traffic starts to resemble production.
Rate-shape matters too. Agent load arrives in waves: a deploy, a viral prompt, a batch job spinning up hundreds of agents. k6 supports exactly this vocabulary - the docs call out spike, stress, and soak tests as distinct test types - so run a soak (steady sessions for 30-60 minutes to find leaks) separately from a stress test (ramp until you find the ceiling) rather than blending them.
The k6 harness
k6 is an open-source, developer-friendly performance tool from Grafana, scriptable in JavaScript, with first-class support for thresholds and metrics, and an extension mechanism (xk6) for adding protocol support beyond its native HTTP, WebSocket, and gRPC clients. For MCP’s request/response methods, plain HTTP POST is enough; SSE subscriptions need either an xk6 extension or a small sidecar harness, which we touch on below.
A session-shaped MCP workload in k6:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
const rpcErrors = new Counter('mcp_rpc_errors');
const methodLatency = new Trend('mcp_method_duration', true);
const BASE = __ENV.MCP_URL || 'http://localhost:8080/mcp';
let id = 0;
function rpc(method, params = {}) {
const body = JSON.stringify({
jsonrpc: '2.0',
id: ++id,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientInfo': { name: 'k6-agent-sim', version: '1.0.0' },
'io.modelcontextprotocol/clientCapabilities': {},
},
},
});
const headers = { 'Content-Type': 'application/json' };
if (__ENV.MCP_TOKEN) headers['Authorization'] = `Bearer ${__ENV.MCP_TOKEN}`;
const start = Date.now();
const res = http.post(BASE, body, { headers, tags: { method } });
methodLatency.add(Date.now() - start, { method });
if (res.status !== 200 || res.json('error')) rpcErrors.add(1, { method });
return res;
}
export const options = {
scenarios: {
focused_worker: {
executor: 'constant-vus',
vus: 50,
duration: '5m',
exec: 'focusedWorker',
},
explorer: {
executor: 'ramping-vus',
startVUs: 0,
stages: [{ duration: '2m', target: 15 }],
exec: 'explorer',
},
},
thresholds: {
mcp_method_duration:['p(95)<2000', 'p(99)<5000'],
mcp_rpc_errors: ['count<50'],
http_req_failed: ['rate<0.01'],
},
};
export function focusedWorker() {
const listed = rpc('tools/list');
check(listed, { 'tools listed': (r) => Array.isArray(r.json('result.tools')) });
const tools = listed.json('result.tools') || [];
if (!tools.length) return;
const pick = tools[Math.floor(Math.random() * tools.length)];
for (let i = 0; i < 8; i++) {
const called = rpc('tools/call', {
name: pick.name,
arguments: { query: `simulated agent query ${i}` },
});
check(called, { 'tool ok': (r) => !r.json('error') });
sleep(Math.random() * 3 + 1); // LLM think time between calls
}
}
export function explorer() {
rpc('server/discover');
const listed = rpc('tools/list');
const tools = listed.json('result.tools') || [];
for (const t of tools.slice(0, 4)) {
rpc('tools/call', { name: t.name, arguments: {} });
sleep(2);
}
sleep(30); // session stays open, thinking
}
Three details that make this an agent test rather than a REST test: every request carries the _meta protocol version and client identity the current spec expects; VUs act as sessions that reuse a connection and sequence calls; and sleep() think-time keeps the server from being flooded in a pattern no real host produces. Run it with:
$ k6 run --env MCP_URL=https://mcp.example.com/mcp --env MCP_TOKEN=$TOKEN mcp-load.js
For SSE, k6’s streaming support for text/event-stream is limited; use an xk6 extension that adds an SSE client, or a small Python harness with httpx that opens subscriptions/listen per VU and measures time-to-first-event. Either way, run the streamer scenario on its own - mixing long-lived streams into the main script muddies the latency percentiles for everything else.
Metrics that matter
Request throughput is the wrong primary metric for an MCP server. These are the ones that map to user-visible agent behavior:
- Per-method latency distributions. Tag every request by JSON-RPC method (
server/discover,tools/list,tools/call,subscriptions/listen) and track p50/p95/p99 per method. Averages hide the discovery burst, and the methods have completely different cost profiles. - Time to first SSE event. The agent-usable latency of a stream is not the stream’s completion - it is when the first event arrives. Track it separately from total stream duration.
- Tool error rate, split by error type. JSON-RPC
errorobjects versus HTTP-level failures tell different stories: the first is your application, the second is transport, auth, or saturation. CountisErrortool results too - a tool returning a definitive error is a business signal, not a server fault, but a spike still matters. - Concurrency per client and stream count. Watch active sessions and open SSE streams against your server’s configured limits. Saturation here shows up as connection refusals long before request latency degrades.
- Serialization cost. Time spent building
tools/listresponses, and response sizes. A 200-tool server returning full schemas on every uncached list is a CPU burn waiting to happen. - Connection churn. Connect/disconnect rate, and whether cleanup keeps up - leaked sessions, sockets, or task handles are the classic soak-test discovery.
Alert thresholds should be SLO-shaped rather than absolute: p95 tools/call under 500 ms for focused-worker sessions, first-event under 1 s for streams, RPC error rate under 0.5%, and - the one people forget - no growth in open sessions or memory between the 10-minute and 60-minute marks of a soak. A server that meets latency targets for 10 minutes and then degrades has a leak, not a capacity problem.
What breaks first
Run this kind of load against a typical MCP server and the failure order is remarkably consistent:
- Blocking work inside tool handlers. A synchronous database call or outbound HTTP request in an async server blocks the event loop; latency for every other method degrades together. This is usually the first ceiling hit, and the per-method latency chart reveals it immediately - unrelated methods slow down in lockstep.
- SSE and stream limits. Servers and their reverse proxies cap concurrent streams or buffer sizes. Under the streamer scenario, streams connect fine at low VUs and then get dropped or stalled at a very specific count - almost always a proxy timeout or a worker-pool limit, not the protocol layer.
- Uncached
tools/listserialization. With dynamic listings and best-effort change notifications, cautious clients re-fetch often. If listing re-serializes full JSON schemas on every call, CPU saturates while request rates still look modest. - Connection churn cleanup. Rapid session open/close leaks - task handles, per-session caches, auth token verifications. Invisible in a 5-minute test; the whole story of a 60-minute soak.
- Backpressure on notifications. When notification delivery outruns slow subscribers, servers that push without per-subscriber buffering or drop policies start consuming memory for clients that are no longer reading.
- Auth validation under load. Token verification that hits a remote introspection endpoint per request becomes the bottleneck the moment concurrency rises - move to locally verified JWTs or cached JWKS before you tune anything else.
Checklist
- Load MCP as sessions, not requests: discovery, listing, and tool calls sequenced per VU with LLM-scale think time between calls.
- Test only the Streamable HTTP transport; stdio servers are out of scope for load testing.
- Tag and report latency per JSON-RPC method; never average across methods.
- Measure time-to-first-SSE-event separately from stream duration, with a dedicated streamer scenario.
- Split error metrics into JSON-RPC errors, HTTP failures, and tool
isErrorresults. - Run soak and stress tests separately: leaks and ceilings are different bugs.
- Watch open session and stream counts against server and proxy limits - saturation shows up there first.
- Verify
tools/listcaching behavior (thettlMsfreshness hint) under your real client mix before optimizing anything else.
Sources: Model Context Protocol, MCP Architecture Overview, MCP Specification, Grafana k6 Documentation, modelcontextprotocol on GitHub