Agent traffic doesn't look like human traffic. It arrives in bursts, sustains long sessions, and hits the same handful of tools at a parallelism a typical API client never produces. Hosted MCP servers are how AI agents reach the tools and data your product exposes, and as those servers move from local subprocesses to network endpoints, you need to know how they behave under that concurrent load. This post shows you how to build a portable Python harness on top of Locust that talks MCP natively, run it locally, and ship the same files to Azure Load Testing without modification. The 15-minute run against four production servers produced 2,293 requests with three failures and surfaced distinct latency signatures per backend.
In this post, I tried to
- Map the MCP life cycle onto Streamable HTTP.
- Write a faithful Locust user class for one MCP server.
- Show how three common auth patterns (none, static bearer, dynamic token) fit into the same harness.
- Run the harness locally and submit the same files to Azure Load Testing.
- Capture what latency signatures look like across four real, production-grade MCP servers.
MCP servers are everywhere. Every team is exposing the tools, data, and actions their product can do so an AI agent can reach in and use them, and an agent is just an LLM running in a loop, looking, thinking, calling a tool, looking again, until the job is done. MCP gives every agent and every tool the same way to talk. Instead of writing a fresh, fragile integration for each new service, you build one MCP server and any agent can use it. Each server offers three simple kinds of things: tools the agent can call to do something, resources it can read for context, and prompts it can reuse. That small, shared vocabulary is what lets one agent reach across many servers (docs, code, your product) without custom glue for each one.
Figure 1. An AI agent runs a four-step loop and reaches multiple hosted MCP servers through a common Streamable HTTP transport.As these servers move from stdio subprocesses to hosted HTTP endpoints serving many tenants, their concurrency profile, latency tail, and failure modes under load become operational questions you have to answer before production. This post restricts its attention to the tools primitive and the Streamable HTTP transport, because they're the only combination that produces interesting load-testing behavior on hosted endpoints. From here, you get a faithful, life-cycle-complete Locust user class for MCP, a clean separation between stateful and stateless server shapes, a small auth-pattern taxonomy, and a baseline measurement across four real, production-grade endpoints.
How MCP talks to a server
Every agent-to-server conversation follows the same formal flow: how the connection opens, how messages are exchanged while it's alive, and how it shuts down cleanly. That flow is the MCP life cycle, and a faithful load test has to mimic it end to end. Otherwise, it isn't testing what a real agent actually puts on the wire.
Streamable HTTP is the load-testable transport
MCP defines two transports: stdio, where the agent spawns the server as a local subprocess and pipes JSON-RPC over its stdin and stdout, and Streamable HTTP, where the server lives on the network. This post focuses on Streamable HTTP because it exposes a network endpoint that Locust can exercise. The stdio transport can still be stress-tested locally, but it doesn't map naturally to network load testing or provide an independent HTTP surface for this harness to test.
The handshake
Throughout the rest of this post, the example server is Microsoft Learn, hosted at https://learn.microsoft.com/api/mcp. Every arrow in the following diagrams is a POST to that URL.
Before the agent can call a single tool, it has to introduce itself and let the server introduce itself back. Three messages, in order, every time:
Figure 2. The three-message handshake that opens an MCP session.
For stateful servers, the session id is the value the agent must preserve. Every subsequent tools/call rides on the same Mcp-Session-Id header until the connection ends, which is exactly what a Locust virtual user has to do too.
One caveat: not every MCP server mints a session. Stateless servers such as Context7 and the Azure DevOps Remote MCP skip the Mcp-Session-Id header entirely. The handshake still happens, but later tools/call requests are standalone POSTs and the harness skips DELETE cleanup. GitHub is bearer-authenticated and session-bearing in this harness; Microsoft Learn is shown with a session id because that was the behavior observed in the demo run reported here. Verify current service behavior before treating it as a permanent contract.
Discovering tools
Once the handshake is done, the agent doesn't yet know what tools the server actually exposes. It asks. The answer is the menu the LLM gets to pick from on every turn.
Figure 3. A tools/list exchange returns the menu of callable tools.
The response is a flat list of tool descriptors: name, human-readable description, and a JSON Schema for the arguments. A real agent feeds this list straight into the LLM's system prompt so the model knows what it can call. A load test doesn't need the LLM in the loop, but it still needs to call tools/list at least once per session: it's how the harness learns the real tool names instead of hard-coding them.
Calling a tool, the request the load test exists to measure
The tools/call request is the primary measurement target. The agent picks a tool from the menu, fills in the arguments, and POSTs a tools/call. The response comes back in one of two shapes. The client asks for both response shapes with a single Accept: application/json, text/event-stream header and the server picks one per request.
The request is ordinary JSON-RPC:
Figure 4. A tools/call request, the request the load test exists to measure.
The response, however, comes back in one of two shapes.
Mode A: application/json. The body is the JSON-RPC payload. One response, no wrapping.
Figure 5. Mode A: single JSON response.
Mode B: text/event-stream. The body is a stream of frames. Each frame is two lines, event: message and data: <json>, and the server can send several before closing.
Figure 6. Mode B: Server-Sent Events with one or more frames.
Same JSON-RPC result either way. The difference is the wrapper: application/json hands you one body to parse; text/event-stream hands you a stream you have to read, split into frames, and pull the data: values from. The harness therefore must check Content-Type on the response and pick the matching reader: calling .json() on an SSE body will throw, and hard-coding either mode means the harness will work against some servers and fail against others.
Closing the session
When a stateful client is done, it tells the server to terminate the session. One DELETE with the session id lets the server release the associated state.
Figure 7. A polite client closes the session with a single DELETE.Stateless servers don't need this step because there's no session to delete, so the agent simply stops sending requests. For stateful servers, skipping the DELETE is usually recoverable because sessions eventually time out, but explicit termination releases server-side state sooner. A polite client always cleans up.
Resources and prompts use the same model
MCP defines two other primitives alongside tools: resources (read-oriented data the agent can pull into context, sometimes generated dynamically) and prompts (reusable templates the user can invoke). Both follow the same JSON-RPC + JSON/SSE shape shown above.
Figure 8. The companion primitives: resources/list and prompts/list.
This post focuses on tools only. Tool calls are the primary workload for this harness because they exercise the highest-cost and highest-risk actions agents invoke repeatedly. Resources are read-oriented operations and may still involve server-side generation; prompts are reusable templates. If an MCP server is going to fall over under load, it'll fall over on a tools/call. So that's the path measured here.
Start with one server: learn_first.py
Before the harness is generalized, the entire MCP life cycle is written by hand against one server. The target is the public Microsoft Learn MCP at https://learn.microsoft.com/api/mcp. No auth, stateful (it issues a session id on initialize), spec-compliant Streamable HTTP. Five blocks, each one of the four wire phases plus the data they operate on. Everything below could live in a single file with one import: Locust.
Define the data and target
A list of queries to rotate through, and the standard Locust FastHttpUser pointed at the host. mcp_path and protocol_version are constants reused in every request below.
import json, random, time, itertools
from locust import FastHttpUser, constant, task
QUERIES = [
"azure functions cold start",
"entra id conditional access",
"aks cluster autoscaler",
"cosmos db throughput rules",
]
_id_counter = itertools.count(1)
class LearnUser(FastHttpUser):
host = "https://learn.microsoft.com"
mcp_path = "/api/mcp"
protocol_version = "2025-06-18"
session_id: str | None = None
# pace each VU at one task per second
wait_time = constant(1)
Initialize the session
Locust calls on_start once per virtual user. The harness POSTs a JSON-RPC initialize with the protocol version and a client name, then reads the Mcp-Session-Id header off the response. Every subsequent request has to send it back. The server replies as Server-Sent Events, so the body is data: {...} framed; the JSON is plucked off the data: line.
def on_start(self):
rpc_id = next(_id_counter)
payload = {
"jsonrpc": "2.0", "id": rpc_id, "method": "initialize",
"params": {
"protocolVersion": self.protocol_version,
"capabilities": {},
"clientInfo": {"name": "learn-first", "version": "1.0.0"},
},
}
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
with self.client.post(self.mcp_path, data=json.dumps(payload),
headers=headers, name="learn:initialize",
catch_response=True) as r:
r.raise_for_status()
self.session_id = r.headers.get("Mcp-Session-Id")
if not self.session_id:
r.failure("no Mcp-Session-Id"); return
r.success()
self._notify_initialized()
self._tools_list()
Confirm the handshake and list tools
Two more POSTs to finish opening the session. The notification tells the server the client is ready (it returns 202 with no body). tools/list is what would be parsed to discover tool names dynamically. For this script the names are already known, so it is called purely to honor the life cycle and record the latency.
def _headers(self):
return {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": self.protocol_version,
"Mcp-Session-Id": self.session_id,
}
def _notify_initialized(self):
payload = {"jsonrpc": "2.0", "method": "notifications/initialized"}
with self.client.post(self.mcp_path, data=json.dumps(payload),
headers=self._headers(),
name="learn:notifications/initialized",
catch_response=True) as r:
r.success() if r.status_code == 202 else r.failure(f"got {r.status_code}")
def _tools_list(self):
rpc_id = next(_id_counter)
payload = {"jsonrpc": "2.0", "id": rpc_id, "method": "tools/list"}
with self.client.post(self.mcp_path, data=json.dumps(payload),
headers=self._headers(),
name="learn:tools/list",
catch_response=True) as r:
r.raise_for_status(); r.success()
Call a tool under load
This is what repeats. Each @task is one tool call; the integer is the weight, so Locust picks each method 50% of the time. The wire shape is identical to tools/list, just with method tools/call and a params block carrying the tool name and arguments. The response comes back SSE-framed; the harness walks the data: lines to find the frame whose id matches the request.
Note. This introductory script hard-codes the SSE reader because Microsoft Learn always returns text/event-stream for tools/call. The dynamic Content-Type check called out earlier is added once when the harness is generalized in the shared base class.
def _call(self, tool: str, arguments: dict):
rpc_id = next(_id_counter)
payload = {
"jsonrpc": "2.0", "id": rpc_id, "method": "tools/call",
"params": {"name": tool, "arguments": arguments},
}
with self.client.post(self.mcp_path, data=json.dumps(payload),
headers=self._headers(),
name=f"learn:tools/call:{tool}",
catch_response=True) as r:
r.raise_for_status()
obj = self._parse_sse(r.content, rpc_id)
if "error" in obj:
r.failure(f"jsonrpc error: {obj['error']}"); return
r.success()
@staticmethod
def _parse_sse(body: bytes, want_id: int) -> dict:
for ev in body.decode("utf-8", "replace").split("\n\n"):
data = "\n".join(ln[5:].lstrip() for ln in ev.splitlines()
if ln.startswith("data:"))
if not data: continue
obj = json.loads(data)
if obj.get("id") == want_id: return obj
return {}
@task(1)
def docs_search(self):
self._call("microsoft_docs_search", {"query": random.choice(QUERIES)})
@task(1)
def code_sample_search(self):
self._call("microsoft_code_sample_search", {"query": random.choice(QUERIES)})
Close the session cleanly
Locust calls on_stop when a VU is shutting down. The spec asks the client to terminate the session explicitly. One DELETE to the same path with the session id, and that's it.
def on_stop(self):
if not self.session_id: return
with self.client.delete(self.mcp_path, headers=self._headers(),
name="learn:DELETE",
catch_response=True) as r:
r.success() if r.status_code in (200, 202, 204, 404, 405) \
else r.failure(f"got {r.status_code}")
self.session_id = None
This compact sample treats 404 and 405 as non-fatal teardown responses so repeated local runs do not fail on cleanup quirks. For a strict stateful server benchmark, 405 should be investigated or counted separately because it means the endpoint did not accept the DELETE cleanup.
That is a complete, runnable script for one MCP server: initialize, notify, list, call, delete. Saved as learn_first.py. The only dependency is Locust itself:
# with uv
uv pip install "locust>=2.31"
# or with pip in an active venv
python -m pip install "locust>=2.31"
Then run it. This produces a six-row Locust table:
# with uv
uv run locust -f learn_first.py --headless -u 5 -r 1 -t 30s
# or, inside an active venv
python -m locust -f learn_first.py --headless -u 5 -r 1 -t 30s
After 30 seconds, Locust prints a summary table. One row per request name, with counts and latency in milliseconds. The illustrative output below is from a small bring-up run; the production-scale numbers shown later in this post are from a different 15-minute run on Azure Load Testing and won't match line for line.
| Endpoint | # reqs | fails | p50 | p95 |
|---|---|---|---|---|
| learn:initialize | 5 | 0 | 820 | 850 |
| learn:notifications/initialized | 5 | 0 | 620 | 660 |
| learn:tools/list | 5 | 0 | 320 | 330 |
| learn:tools/call:microsoft_docs_search | 36 | 0 | 880 | 1500 |
| learn:tools/call:microsoft_code_sample_search | 29 | 0 | 860 | 1500 |
| learn:DELETE | 5 | 0 | 380 | 510 |
| Aggregated | 85 | 0 | 820 | 1400 |
Five virtual users for thirty seconds drove 85 requests at ~2.9 RPS with zero failures. Each VU sleeps one second between tasks (wait_time = constant(1)), so the rate is paced rather than going as fast as the network allows. The five lifecycle endpoints (initialize, notifications/initialized, tools/list, DELETE) each ran exactly once per VU; the rest is tools/call, split roughly 50/50 between the two @task methods.
Figure 9. The four wire phases of one MCP session. All four are written inlearn_first.py; onlytools/callrepeats under load.
Three authentication patterns
Before generalizing the script, one more wrinkle: different MCP servers authenticate differently. Three patterns cover all four targets used in this guide, and each one stays a one- or two-line change on the user class.
No auth or optional bearer (Learn, Context7)
Microsoft Learn can be called without a bearer token. Context7 is stateless in this harness and runs without extra auth, or with an optional bearer/API key for quota and reliability. When no header is needed, the class does not declare an auth_headers attribute and the base sends the standard MCP headers untouched.
Static bearer from env (GitHub)
The token lives in GITHUB_MCP_TOKEN (a fine-grained PAT locally, an Azure Key Vault reference in Azure Load Testing). It is read once at import time and stamped on every request via the class attribute:
class GitHubUser(MCPUserBase):
auth_headers = {
"Authorization": f"Bearer {os.environ.get('GITHUB_MCP_TOKEN', '')}",
}
Dynamic token per request (Azure DevOps Remote MCP)
The Azure DevOps Remote MCP is stateless and uses Microsoft Entra ID bearer auth. Azure DevOps tokens expire roughly every hour, so a static value is not suitable for a long run. The class overrides _extra_headers() (a hook the base calls before every POST) and mints a fresh token on demand:
class ADOUser(StatelessMCPUser):
auth_headers = {"X-MCP-Readonly": "true"}
def _extra_headers(self) -> dict[str, str]:
return {
**self.auth_headers,
"Authorization": f"Bearer {_get_ado_token()}",
}
Locally _get_ado_token() is backed by DefaultAzureCredential (cached az login); the SDK auto-refreshes. In Azure Load Testing the same function short-circuits to a pre-minted token injected from Key Vault. The user class does not care which mode is active.
That is the whole auth surface: one class attribute for static headers, one method override for dynamic ones. Everything else (sessions, retries, timing) lives in the base, which is the next section.
A shared base class for every server
π¦ Source code. Everything in this section (_base.py, learn.py, github.py, context7.py, ado.py, multi.py, locust.conf, and the Azure Load Testing YAML) lives in the public repo I maintain: github.com/kroy92/azure-load-test-mcp-server.
learn_first.py works, but every MCP server in this study follows the same five-block flow: initialize, notify, list, call, and DELETE only for session-bearing users. Only the host, the tool names, session posture, and auth headers change. The wire plumbing is therefore lifted into a shared base class, leaving each per-server file with only the configuration and tasks that genuinely differ.
π‘ Why a flat-file framework? The harness keeps _base.py, one server file per MCP endpoint, multi.py, and the config files in a flat project root so Azure Load Testing can upload the same file set directly. That shape avoids packaging steps, keeps each server easy to inspect, and makes local and cloud runs use the same imports.
The same framework is useful before rolling out a custom MCP server: add one user class for the new endpoint, import it from multi.py, and benchmark the server's tool mix locally and in Azure Load Testing before enabling broader agent traffic.
MCPUserBase in _base.py owns the life cycle: on_start runs _initialize, _notify_initialized, and _tools_list; on_stop runs _terminate_session; _call wraps _jsonrpc for every tools/call; wait_time implements the agent-shaped 80/20 think time; _extra_headers is the hook subclasses override to mint auth tokens. Session-bearing users in this harness (Learn as observed in the demo run, and GitHub) inherit MCPUserBase directly. Stateless servers (Context7, ADO Remote) go through StatelessMCPUser, which sets requires_session = False so the DELETE step is a no-op. Each leaf class is then a small set of @task methods.
Figure 10. Class hierarchy. Hollow arrows point at the parent class. Stateful servers inheritMCPUserBasedirectly; stateless servers route throughStatelessMCPUserto skip the DELETE step.Figure 11. multi.py is the composition point: importing each user class is enough for one Locust process to run all four MCP workloads together.
With that base in place, each per-server file is a list of queries, five config constants, and a handful of @task methods. multi.py is twenty lines of imports; Locust loads each User subclass and spawns its declared fixed_count, so one process drives all four servers in parallel. The same files run in Azure Load Testing without modification via azure-loadtest.yaml. The engines just need the same env vars.
Run it locally, then ship it to Azure Load Testing
Four user classes, but the goal is one Locust process driving all of them, and the same files running in Azure Load Testing without edits. multi.py is the entrypoint. It is twenty lines of imports; Locust discovers each User subclass and spawns its declared fixed_count:
from ado import ADOUser
from context7 import Context7User
from github import GitHubUser
from learn import LearnUser
The four fixed_count values must sum to users in locust.conf (default 2 + 5 + 5 + 5 = 17), otherwise Locust warns and rebalances. Run it locally:
$ uv run locust -f multi.py --config locust.conf
Same files. Ship them to Azure Load Testing through a one-page YAML. Azure Load Testing uploads the project, spins up engine instances at the load-test resource's configured scale, runs the same locust -f multi.py behind the scenes, and streams results back to a portal dashboard:
version: v0.1
testId: mcp-multi-flat
displayName: MCP multi-server cloud smoke
testPlan: multi.py
testType: Locust
engineInstances: 1
configurationFiles:
- _base.py
- mcp_client.py
- github.py
- learn.py
- context7.py
- ado.py
- requirements.txt
# β¦ plus query data files
properties:
userPropertyFile: locust.conf
env:
- { name: LOCUST_USERS, value: "17" }
- { name: GITHUB_USERS, value: "2" }
- { name: LEARN_USERS, value: "5" }
- { name: CONTEXT7_USERS, value: "5" }
- { name: ADO_USERS, value: "5" }
- { name: ADO_ORG, value: "<your-ado-org>" }
- { name: ADO_PROJECT, value: "<your-ado-project>" }
# Bearer tokens stay in Key Vault; Azure Load Testingβs system-assigned identity
# resolves them at run time and injects as env vars.
secrets:
- { name: GITHUB_MCP_TOKEN, value: https://<your-keyvault>.vault.azure.net/secrets/github-mcp-token }
- { name: ADO_MCP_TOKEN, value: https://<your-keyvault>.vault.azure.net/secrets/ado-mcp-token }
- { name: CONTEXT7_API_KEY, value: https://<your-keyvault>.vault.azure.net/secrets/context7-api-key }
failureCriteria:
- avg(response_time_ms) > 8000
- percentage(error) > 25
The test is submitted with the Azure CLI. Azure Load Testing pulls the secrets from Key Vault via its system-assigned identity at run time, so the only thing required locally is an az login against the subscription that owns the load-testing resource:
$ az login
$ az load test update \
--load-test-resource <your-loadtest-resource> \
--resource-group <your-rg> \
--test-id mcp-multi-flat \
--load-test-config-file azure-loadtest.yaml
$ az load test-run create \
--load-test-resource <your-loadtest-resource> \
--resource-group <your-rg> \
--test-id mcp-multi-flat \
--test-run-id run-$(date +%Y%m%d-%H%M%S) \
--no-wait
Heads-up: cross-tenant Azure DevOps. The Azure DevOps Remote MCP server uses Microsoft Entra ID, and its tokens expire in about an hour. If the Azure DevOps organization lives in a different tenant from the subscription that hosts Azure Load Testing, sign in to the Azure DevOps tenant before each run, request an access token for the Azure DevOps resource, and write it to the ado-mcp-token Key Vault secret. Same tenant for both? Skip it entirely. When Locust runs locally, az login reads the secrets from Key Vault, and when Azure Load Testing runs the test in the cloud, the load-test resource's managed identity does the same thing.
Three design choices keep the run reproducible. Auth. Secret values are resolved from Key Vault at run time and are not stored in source files or the YAML definition. Azure Load Testing reads them via managed identity, and the user classes read the same env-var names locally and in cloud. Failure criteria. Turn the YAML into a pass/fail gate suitable for a release pipeline. Engine count. Increase engineInstances to spread the same VU mix across more workers.
What one run actually puts on the wire
Same four files, real endpoints. Before the run output, this section describes what one Locust process actually puts on the wire when launched.
Workload model
Seventeen virtual users split four ways, each VU picking tools from its server's @task weights, paced by the agent-shaped think time (80% short intra-turn pauses 0.1β0.5 s, 20% inter-turn idles 5β30 s).
Figure 12. One Locust process β four MCP servers. Each card's bars are the per-tool @task weights inside that Userclass.
Local validation
Before sending the workload to the cloud, run it in one Python process locally. locust.conf pins the settings (and is the same file Azure Load Testing consumes via userPropertyFile):
# locust.conf
locustfile = multi.py
headless = true
users = 17 # 2 GitHub + 5 Learn + 5 Context7 + 5 ADO
spawn-rate = 5
run-time = 15m
csv = results/run
html = results/run.html
Then launch a short local run:
$ locust --config locust.conf --run-time 2m \
--csv results/local-2m --html results/local-2m.html
Useful for catching a broken assertion or an expired token in two minutes instead of fifteen. The headline run reported in this post is the cloud-path execution submitted to Azure Load Testing.
Cloud execution
Same project root, same multi.py, same locust.conf. Push the YAML, then start the run:
$ az login
$ az load test update \
--load-test-resource <your-loadtest-resource> \
--resource-group <your-rg> \
--test-id mcp-multi-flat \
--load-test-config-file azure-loadtest.yaml
$ az load test-run create \
--load-test-resource <your-loadtest-resource> \
--resource-group <your-rg> \
--test-id mcp-multi-flat \
--test-run-id run-$(date +%Y%m%d-%H%M%S) \
--no-wait
Azure Load Testing spins up an engine, pulls the secrets from Key Vault via its managed identity, fans out to the four MCP servers, and writes engine1_results.csv back to blob storage.
What the 15-minute run produced
These numbers come from one 15-minute run on a single Azure Load Testing engine on 2026-05-09, with 17 virtual users split across four servers. Treat them as a baseline at light load, not a capacity ceiling.
The headline run completed in 15 minutes with a PASSED verdict. Check the status and result with az load test-run show:
# status + verdict
$ az load test-run show --test-run-id run-2026-05-09-15m-clean-172854 ...
{
"status": "DONE",
"testResult": "PASSED",
"start": "2026-05-09T11:59:20Z",
"end": "2026-05-09T12:14:25Z"
}
The run sent 2,293 requests and recorded 3 failures (0.13%). Broken out by label:
Total: 2293 | Failures: 3 (0.13%)
=== By label (totals + failures) ===
Count Fail Label
----- ---- -----
441 0 context7:tools/call:query-docs
372 0 learn:tools/call:microsoft_docs_search
343 0 learn:tools/call:microsoft_code_sample_search
328 0 ado:tools/call:wit_work_item
161 0 context7:tools/call:resolve-library-id
125 0 ado:tools/call:search_workitem
109 0 ado:tools/call:repo_repository
84 3 github:tools/call:search_code
75 0 github:tools/call:search_repositories
72 0 ado:tools/call:pipelines_build
64 0 github:tools/call:get_file_contents
31 0 github:tools/call:list_issues
30 0 github:tools/call:get_me
Per-server latency signatures
Azure Load Testing bundles a self-contained dashboard with every run: charts, error explorer, per-minute time series. The summary diagrams below distill that into one chart per server. Blue bar = avg, orange extension = avg β p90, label = avg / p90 in milliseconds.
Download the dashboard locally with az load test-run download-files:
$ az load test-run download-files \
--load-test-resource <your-loadtest-resource> \
--resource-group <your-rg> \
--test-run-id run-2026-05-09-15m-clean-172854 \
--path ./report --report --force
The dashboard from the run reported here is live at kroy92.github.io/azure-load-test-mcp-server/sample-report (or browse the source files on GitHub).
Figure 13. Microsoft Learn: public docs API, no auth. The fastest of the four observed in this run.
Observation. Both doc-search tools sit tightly under 900 ms p90 with zero errors, consistent with a CDN-fronted, cached search index serving anonymous traffic.
Figure 14. GitHub: PAT-authenticated.search_codeis bimodal; the slow tail and the run's only failures look consistent with rate-limit backoff.
Observation. search_codehas a 554 ms average but a 1.56 s p90 with 3 errors, consistent with GitHub API rate-limit throttling on the slow tail.
Figure 15. Context7: doc retrieval is genuinely heavy work. The slowest tier in this run, but predictable.
Observation. Both tool calls sit above 1 s average, consistent with a synchronous semantic-search / embedding lookup on every request, with no caching layer.
Figure 16. Azure DevOps Remote: Entra-authenticated, per-request bearer. The most consistent server in this run, with tight p90 spread.
Observation. Handshakeinitializeis 335 ms, the heaviest of the four, consistent with Entra bearer-token validation on every new session.
What the numbers suggest
Three tiers of latency
The four servers fall into three rough tiers in this run. Microsoft Learn is the fastest (sub-900 ms p90 on both tools), which may reflect cached, anonymous content delivery. The Azure DevOps Remote MCP is the most consistent (a tight gap between average and p90 across all five tools), which looks consistent with a stable internal service backed by deterministic queries. Context7 is the slowest (over 1 s on every call), which is consistent with a synchronous embedding lookup per request. GitHub sits in the middle, but search_code is bimodal, with a 2.8Γ average-to-p90 ratio and the run's only failures, which may reflect API-level rate limiting.
The handshake isn't free
Cold-session cost varies tenfold across the four servers in this run, from 44 ms (Learn tools/list) to 335 ms (ADO initialize). For agents that open and close many short-lived sessions, this overhead matters. The result argues for session reuse where possible, though that's a property of the agent runtime, not of MCP itself.
Limitations
- Single-engine run. The reported run uses one Azure Load Testing engine instance. Higher concurrency (engineInstances > 1) wasn't exercised in this post and would be required to characterize saturation behavior.
- Modest VU count. Seventeen virtual users is a smoke-grade workload, not a stress test. The numbers reported are a baseline at light load, not a capacity ceiling.
- Non-replayable third-party endpoints. Three of the four servers are external services with their own rate limits, terms of service, and operational variability. Numbers reported here describe the state of those services on the run date and shouldn't be treated as service-level commitments.
- Tools only. The harness exercises tools/call exclusively. Resources and prompts use the same wire format but weren't measured.
One operational note
Operational note. Load testing third-party hosted MCP servers should be done with permission and within the operator's published rate limits. The targets used in this post are public endpoints; the workload is deliberately small. If you replicate this work against services you don't own, review terms of service first.
Wrap-up
You can build a protocol-faithful load-testing harness for hosted MCP servers in a few hundred lines of Python on top of Locust. The key design choices (a stateful/stateless split at the base class, an auth hook that supports static and dynamic tokens, and a response reader that handles both application/json and text/event-stream) make the harness portable across the hosted MCP servers exercised in this study and likely across most others in the same shape.
Use this harness as a baseline for your own hosted MCP servers: start with one server locally to validate the wire behavior, then submit the same YAML to Azure Load Testing to run it under managed load with pass/fail gates via failureCriteria. The same project files run without modification from a developer laptop and from Azure Load Testing engines in the cloud, with secrets resolved through Key Vault by managed identity at run time. The run reported here surfaces distinct, interpretable latency signatures across four production servers and shows the harness is suitable both for development-time smoke checks and for release-pipeline gates. From there, the obvious extensions are the resources and prompts primitives, ramp-and-soak workload profiles, and multi-engine fan-out for saturation characterization.
Learn more
- https://modelcontextprotocol.io/specification, Anthropic, version 2025-06-18.
- Locust documentation, the load testing framework this harness builds on.
- Azure Load Testing documentation, the managed load testing service on Azure.
Get the code
The full harness (_base.py, the four per-server user classes, multi.py, locust.conf, and the Azure Load Testing YAML) lives in kroy92/azure-load-test-mcp-server on GitHub. Clone the repo, install with uv, and you can run the same multi.py shown in this post locally before pushing the YAML to Azure Load Testing.
Built with Locust 2.43, Python 3.13, and uv.