TL;DR: O tráfego de agentes de IA não se parece com tráfego humano: chega em rajadas, sustenta sessões longas e atinge as mesmas ferramentas com paralelismo incomum. Este artigo mostra como construir um harness portátil em Python sobre Locust que fala MCP nativamente, executá-lo localmente e enviar os mesmos arquivos para Azure Load Testing. Um run de 15 minutos contra quatro servidores em produção produziu 2.293 requisições com três falhas e revelou assinaturas de latência distintas por backend. A conclusão para times brasileiros: é possível validar desempenho de servidores MCP antes de expô-los a agentes reais, usando a mesma base de código em desenvolvimento e em pipeline de release.
O tráfego de agente não parece com tráfego humano. Ele chega em bursts, sustenta sessões longas e atinge o mesmo punhado de ferramentas com um paralelismo que um cliente API típico nunca produz. Servidores MCP hospedados são como agentes de IA alcançam as ferramentas e os dados que seu produto expõe e, à medida que esses servidores migram de subprocessos locais para endpoints de rede, você precisa saber como eles se comportam sob essa carga concorrente. Este post mostra como construir um harness portátil em Python sobre Locust que fala MCP nativamente, executá-lo localmente e enviar os mesmos arquivos para Azure Load Testing sem modificação. O run de 15 minutos contra quatro servidores em produção produziu 2.293 requisições com três falhas e revelou assinaturas de latência distintas por backend.
Neste post, o autor mapeou o ciclo de vida do MCP sobre Streamable HTTP, escreveu uma classe de usuário Locust fiel para um servidor MCP, mostrou como três padrões comuns de autenticação (nenhum, bearer estático, token dinâmico) se encaixam no mesmo harness, executou o harness localmente e submeteu os mesmos arquivos para Azure Load Testing, e capturou as assinaturas de latência em quatro servidores MCP reais de produção.
Servidores MCP estão em toda parte. Cada time está expondo as ferramentas, dados e ações que seu produto pode fazer para que um agente de IA possa alcançá-los e usá-los. Um agente é apenas um LLM rodando em um loop: olhar, pensar, chamar uma ferramenta, olhar novamente, até o trabalho ser concluído. O MCP dá a cada agente e a cada ferramenta a mesma forma de conversar. Em vez de escrever uma integração nova e frágil para cada serviço, você constrói um servidor MCP e qualquer agente pode usá-lo. Cada servidor oferece três tipos simples de coisas: tools que o agente pode chamar para fazer algo, resources que ele pode ler para contexto e prompts que ele pode reutilizar. Esse vocabulário pequeno e compartilhado é o que permite que um agente alcance vários servidores (docs, código, seu produto) sem cola personalizada para cada um.
Figura 1. Um agente de IA executa um loop de quatro etapas e alcança múltiplos servidores MCP hospedados através de um transporte Streamable HTTP comum.
À medida que esses servidores migram de subprocessos stdio para endpoints HTTP hospedados atendendo a vários tenants, seu perfil de concorrência, cauda de latência e modos de falha sob carga tornam-se questões operacionais que você precisa responder antes da produção. Este post restringe sua atenção ao primitive tools e ao transporte Streamable HTTP, porque são a única combinação que produz comportamento interessante de teste de carga em endpoints hospedados. A partir daqui, você obtém uma classe de usuário Locust fiel e completa do ciclo de vida para MCP, uma separação clara entre servidores stateful e stateless, uma pequena taxonomia de padrões de autenticação e uma medição de linha de base em quatro endpoints reais de produção.
Como o MCP se comunica com um servidor?
Cada conversa agente-servidor segue o mesmo fluxo formal: como a conexão abre, como as mensagens são trocadas enquanto está ativa e como ela é encerrada corretamente. Esse fluxo é o ciclo de vida do MCP e um teste de carga fiel tem que imitá-lo de ponta a ponta. Caso contrário, não está testando o que um agente real realmente coloca no fio.
Streamable HTTP é o transporte testável em carga
O MCP define dois transportes: stdio, onde o agente spawna o servidor como um subprocesso local e canaliza JSON-RPC sobre stdin/stdout, e Streamable HTTP, onde o servidor vive na rede. Este post foca em Streamable HTTP porque expõe um endpoint de rede que Locust pode exercitar. O transporte stdio ainda pode ser testado sob estresse localmente, mas não mapeia naturalmente para teste de carga em rede nem fornece uma superfície HTTP independente para este harness testar.
O handshake
Ao longo deste post, o servidor de exemplo é o Microsoft Learn, hospedado em https://learn.microsoft.com/api/mcp. Cada seta nos diagramas a seguir é um POST para essa URL.
Antes que o agente possa chamar uma única ferramenta, ele precisa se apresentar e deixar o servidor se apresentar de volta. Três mensagens, em ordem, toda vez:
Figura 2. O handshake de três mensagens que abre uma sessão MCP.
Para servidores stateful, o session id é o valor que o agente deve preservar. Cada tools/call subsequente viaja no mesmo cabeçalho Mcp-Session-Id até a conexão terminar, que é exatamente o que um usuário virtual Locust precisa fazer também.
Uma ressalva: nem todo servidor MCP gera uma sessão. Servidores stateless como Context7 e Azure DevOps Remote MCP pulam o cabeçalho Mcp-Session-Id completamente. O handshake ainda acontece, mas requisições tools/call posteriores são POSTs independentes e o harness pula a limpeza DELETE. GitHub é autenticado por bearer e mantém sessão neste harness; Microsoft Learn é mostrado com session id porque foi o comportamento observado na execução de demonstração relatada aqui. Verifique o comportamento atual do serviço antes de tratá-lo como um contrato permanente.
Descobrindo ferramentas
Uma vez que o handshake está feito, o agente ainda não sabe quais ferramentas o servidor realmente expõe. Ele pergunta. A resposta é o menu que o LLM pode escolher a cada turno.
Figura 3. Uma troca tools/list retorna o menu de ferramentas chamáveis.
A resposta é uma lista plana de descritores de ferramenta: nome, descrição legível por humanos e um JSON Schema para os argumentos. Um agente real alimenta essa lista diretamente no system prompt do LLM para que o modelo saiba o que pode chamar. Um teste de carga não precisa do LLM no loop, mas ainda precisa chamar tools/list pelo menos uma vez por sessão: é como o harness aprende os nomes reais das ferramentas em vez de codificá-los.
Chamando uma ferramenta: a requisição que o teste de carga existe para medir
A requisição tools/call é o principal alvo de medição. O agente escolhe uma ferramenta do menu, preenche os argumentos e faz um POST de tools/call. A resposta vem em uma de duas formas. O cliente pede ambas as formas de resposta com um único cabeçalho Accept: application/json, text/event-stream e o servidor escolhe uma por requisição.
A requisição é um JSON-RPC comum:
Figura 4. Uma requisição tools/call, a requisição que o teste de carga existe para medir.
A resposta, no entanto, vem em uma de duas formas.
Modo A: application/json. O corpo é o payload JSON-RPC. Uma resposta, sem encapsulamento.
Figura 5. Modo A: resposta JSON única.
Modo B: text/event-stream. O corpo é um stream de frames. Cada frame tem duas linhas, event: message e data: <json>, e o servidor pode enviar várias antes de fechar.
Figura 6. Modo B: Server-Sent Events com um ou mais frames.
Mesmo resultado JSON-RPC de qualquer forma. A diferença é o wrapper: application/json entrega um corpo para parsear; text/event-stream entrega um stream que você precisa ler, dividir em frames e extrair os valores data:. O harness portanto deve verificar Content-Type na resposta e escolher o leitor correspondente: chamar .json() em um corpo SSE lançará um erro, e codificar manualmente qualquer modo significa que o harness funcionará contra alguns servidores e falhará contra outros.
Fechando a sessão
Quando um cliente stateful termina, ele avisa o servidor para encerrar a sessão. Um DELETE com o session id permite que o servidor libere o estado associado.
Figura 7. Um cliente educado encerra a sessão com um único DELETE.
Servidores stateless não precisam desta etapa porque não há sessão a ser deletada, então o agente simplesmente para de enviar requisições. Para servidores stateful, pular o DELETE geralmente é recuperável porque as sessões eventualmente expiram, mas a terminação explícita libera o estado do servidor mais cedo. Um cliente educado sempre limpa.
Recursos e prompts usam o mesmo modelo
O MCP define duas outras primitivas além de tools: resources (dados orientados a leitura que o agente pode puxar para o contexto, às vezes gerados dinamicamente) e prompts (templates reutilizáveis que o usuário pode invocar). Ambos seguem a mesma forma JSON-RPC + JSON/SSE mostrada acima.
Figura 8. As primitivas complementares: resources/list e prompts/list.
Este post foca apenas em tools. Chamadas de ferramenta são a carga de trabalho principal deste harness porque exercem as ações de maior custo e maior risco que os agentes invocam repetidamente. Recursos são operações orientadas a leitura e podem ainda envolver geração do lado do servidor; prompts são templates reutilizáveis. Se um servidor MCP vai cair sob carga, vai cair em um tools/call. Então esse é o caminho medido aqui.
Como começar com um servidor: learn_first.py
Antes de generalizar o harness, todo o ciclo de vida do MCP é escrito manualmente contra um servidor. O alvo é o Microsoft Learn MCP público em https://learn.microsoft.com/api/mcp. Sem auth, stateful (emite um session id em initialize), Streamable HTTP compatível com a especificação. Cinco blocos, cada uma das quatro fases de rede mais os dados que operam. Tudo abaixo poderia viver em um único arquivo com uma importação: Locust.
Defina os dados e o alvo
Uma lista de consultas para rotacionar e o padrão FastHttpUser do Locust apontado para o host. mcp_path e protocol_version são constantes reutilizadas em cada requisição abaixo.
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)
Inicialize a sessão
Locust chama on_start uma vez por usuário virtual. O harness faz um POST de initialize JSON-RPC com a versão do protocolo e um nome de cliente, então lê o cabeçalho Mcp-Session-Id da resposta. Cada requisição subsequente precisa enviá-lo de volta. O servidor responde como Server-Sent Events, então o corpo é data: {...} emoldurado; o JSON é extraído da linha data:.
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()
Confirme o handshake e liste ferramentas
Mais dois POSTs para finalizar a abertura da sessão. A notificação informa ao servidor que o cliente está pronto (retorna 202 sem corpo). tools/list é o que seria parseado para descobrir nomes de ferramentas dinamicamente. Para este script os nomes já são conhecidos, então é chamado apenas para honrar o ciclo de vida e registrar a latência.
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()
Chame uma ferramenta sob carga
Isso é o que se repete. Cada @task é uma chamada de ferramenta; o inteiro é o peso, então Locust escolhe cada método 50% das vezes. A forma do wire é idêntica a tools/list, apenas com método tools/call e um bloco params carregando o nome da ferramenta e argumentos. A resposta volta emoldurada em SSE; o harness percorre as linhas data: para encontrar o frame cujo id corresponde à requisição.
Nota. Este script introdutório codifica manualmente o leitor SSE porque o Microsoft Learn sempre retorna
text/event-streamparatools/call. A verificação dinâmica deContent-Typemencionada anteriormente é adicionada quando o harness é generalizado na classe base compartilhada.
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)})
Feche a sessão adequadamente
Locust chama on_stop quando um VU está sendo encerrado. A especificação pede que o cliente termine a sessão explicitamente. Um DELETE para o mesmo caminho com o session id, e pronto.
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
Esta amostra compacta trata 404 e 405 como respostas de encerramento não fatais para que execuções locais repetidas não falhem em peculiaridades de limpeza. Para um benchmark rigoroso de servidor stateful, 405 deve ser investigado ou contado separadamente porque significa que o endpoint não aceitou a limpeza DELETE.
Isso é um script completo e executável para um servidor MCP: initialize, notify, list, call, delete. Salvo como learn_first.py. A única dependência é o próprio Locust:
# with uv
uv pip install "locust>=2.31"
# or with pip in an active venv
python -m pip install "locust>=2.31"
Então execute. Isso produz uma tabela Locust de seis linhas:
# 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
Após 30 segundos, Locust imprime uma tabela resumida. Uma linha por nome de requisição, com contagens e latência em milissegundos. A saída ilustrativa abaixo é de uma pequena execução de inicialização; os números em escala de produção mostrados mais adiante neste post são de um run diferente de 15 minutos no Azure Load Testing e não corresponderão linha por linha.
| 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 |
Cinco usuários virtuais por trinta segundos geraram 85 requisições a ~2,9 RPS com zero falhas. Cada VU dorme um segundo entre tarefas (wait_time = constant(1)), então a taxa é ritmada em vez de ir tão rápido quanto a rede permite. Os cinco endpoints do ciclo de vida (initialize, notifications/initialized, tools/list, DELETE) rodaram exatamente uma vez por VU; o resto é tools/call, dividido aproximadamente 50/50 entre os dois métodos @task.
Figura 9. As quatro fases de rede de uma sessão MCP. Todas as quatro estão escritas em learn_first.py; apenas tools/call se repete sob carga.
Três padrões de autenticação
Antes de generalizar o script, mais um detalhe: diferentes servidores MCP autenticam de formas diferentes. Três padrões cobrem todos os quatro alvos usados neste guia, e cada um fica como uma alteração de uma ou duas linhas na classe de usuário.
Sem auth ou bearer opcional (Learn, Context7)
Microsoft Learn pode ser chamado sem token bearer. Context7 é stateless neste harness e roda sem auth extra, ou com um bearer/API key opcional para cota e confiabilidade. Quando nenhum cabeçalho é necessário, a classe não declara um atributo auth_headers e a base envia os cabeçalhos MCP padrão intactos.
Bearer estático de env (GitHub)
O token vive em GITHUB_MCP_TOKEN (um PAT refinado localmente, uma referência do Azure Key Vault no Azure Load Testing). É lido uma vez na importação e carimbado em cada requisição via atributo de classe:
class GitHubUser(MCPUserBase):
auth_headers = {
"Authorization": f"Bearer {os.environ.get('GITHUB_MCP_TOKEN', '')}",
}
Token dinâmico por requisição (Azure DevOps Remote MCP)
O Azure DevOps Remote MCP é stateless e usa autenticação bearer do Microsoft Entra ID. Tokens do Azure DevOps expiram aproximadamente a cada hora, então um valor estático não é adequado para um run longo. A classe sobrescreve _extra_headers() (um hook que a base chama antes de cada POST) e gera um token fresco sob demanda:
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()}",
}
Localmente _get_ado_token() é alimentado por DefaultAzureCredential (cached az login); o SDK auto-atualiza. No Azure Load Testing a mesma função faz um atalho para um token pré-cunhado injetado do Key Vault. A classe de usuário não se importa qual modo está ativo.
Essa é toda a superfície de auth: um atributo de classe para cabeçalhos estáticos, uma sobrescrita de método para dinâmicos. Todo o resto (sessões, retries, temporização) vive na base, que é a próxima seção.
Uma classe base compartilhada para cada servidor
📦 Código fonte. Tudo nesta seção (
_base.py,learn.py,github.py,context7.py,ado.py,multi.py,locust.confe o YAML do Azure Load Testing) vive no repositório público mantido pelo autor: github.com/kroy92/azure-load-test-mcp-server.
learn_first.py funciona, mas todo servidor MCP neste estudo segue o mesmo fluxo de cinco blocos: initialize, notify, list, call e DELETE apenas para usuários com sessão. Apenas o host, os nomes das ferramentas, a postura da sessão e os cabeçalhos de auth mudam. O encanamento do wire é portanto elevado para uma classe base compartilhada, deixando cada arquivo por servidor apenas com a configuração e as tarefas que realmente diferem.
💡 Por que uma estrutura flat-file? O harness mantém
_base.py, um arquivo de servidor por endpoint MCP,multi.pye os arquivos de configuração em uma raiz de projeto plana para que o Azure Load Testing possa fazer upload do mesmo conjunto de arquivos diretamente. Essa forma evita etapas de empacotamento, mantém cada servidor fácil de inspecionar e faz com que execuções locais e na nuvem usem as mesmas importações.
A mesma estrutura é útil antes de lançar um servidor MCP personalizado: adicione uma classe de usuário para o novo endpoint, importe-a de multi.py e faça benchmark da mistura de ferramentas do servidor localmente e no Azure Load Testing antes de habilitar tráfego de agente mais amplo.
MCPUserBase em _base.py é dona do ciclo de vida: on_start executa _initialize, _notify_initialized e _tools_list; on_stop executa _terminate_session; _call envolve _jsonrpc para cada tools/call; wait_time implementa o tempo de pensamento 80/20 modelado para agente; _extra_headers é o hook que subclasses sobrescrevem para cunhar tokens de auth. Usuários com sessão neste harness (Learn como observado no run de demonstração, e GitHub) herdam MCPUserBase diretamente. Servidores stateless (Context7, ADO Remote) passam por StatelessMCPUser, que define requires_session = False para que a etapa DELETE seja um no-op. Cada classe folha é então um pequeno conjunto de métodos @task.
Figura 10. Hierarquia de classes. Setas vazias apontam para a classe pai. Servidores stateful herdam MCPUserBase diretamente; servidores stateless roteiam através de StatelessMCPUser para pular a etapa DELETE.
Figura 11. multi.py é o ponto de composição: importar cada classe de usuário é suficiente para um processo Locust executar todas as quatro cargas de trabalho MCP juntas.
Com essa base em vigor, cada arquivo por servidor é uma lista de consultas, cinco constantes de configuração e um punhado de métodos @task. multi.py tem vinte linhas de imports; Locust carrega cada subclasse User e spawna seu fixed_count declarado, então um processo aciona todos os quatro servidores em paralelo. Os mesmos arquivos rodam no Azure Load Testing sem modificação via azure-loadtest.yaml. Os engines só precisam das mesmas variáveis de ambiente.
Como executar localmente e depois enviar para Azure Load Testing?
Quatro classes de usuário, mas o objetivo é um processo Locust acionando todas elas, e os mesmos arquivos rodando no Azure Load Testing sem edições. multi.py é o ponto de entrada. São vinte linhas de imports; Locust descobre cada subclasse User e spawna seu fixed_count declarado:
from ado import ADOUser
from context7 import Context7User
from github import GitHubUser
from learn import LearnUser
Os quatro valores fixed_count devem somar users em locust.conf (default 2 + 5 + 5 + 5 = 17), caso contrário Locust avisa e rebalanceia. Execute localmente:
$ uv run locust -f multi.py --config locust.conf
Mesmos arquivos. Envie-os para Azure Load Testing através de um YAML de uma página. O Azure Load Testing faz upload do projeto, sobe instâncias de engine na escala configurada do recurso de load test, executa o mesmo locust -f multi.py nos bastidores e transmite os resultados de volta para um dashboard no portal:
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
O teste é submetido com o Azure CLI. O Azure Load Testing puxa os segredos do Key Vault via sua identidade gerenciada em tempo de execução, então a única coisa necessária localmente é um az login contra a assinatura que possui o recurso de load test:
$ 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
Atenção: cross-tenant Azure DevOps. O servidor Azure DevOps Remote MCP usa Microsoft Entra ID, e seus tokens expiram em cerca de uma hora. Se a organização do Azure DevOps viver em um tenant diferente da assinatura que hospeda o Azure Load Testing, faça login no tenant do Azure DevOps antes de cada execução, solicite um token de acesso para o recurso do Azure DevOps e escreva-o no segredo ado-mcp-token do Key Vault. Mesmo tenant para ambos? Pule isso completamente. Quando Locust roda localmente, az login lê os segredos do Key Vault, e quando o Azure Load Testing roda o teste na nuvem, a identidade gerenciada do recurso de load test faz o mesmo.
Três escolhas de design mantêm a execução reproduzível. Auth. Valores secretos são resolvidos do Key Vault em tempo de execução e não são armazenados em arquivos fonte ou na definição YAML. O Azure Load Testing os lê via identidade gerenciada, e as classes de usuário leem os mesmos nomes de variáveis de ambiente localmente e na nuvem. Failure criteria. Transforme o YAML em um gate de pass/falha adequado para um pipeline de release. Engine count. Aumente engineInstances para espalhar a mesma mistura de VUs entre mais workers.
O que um run realmente coloca no fio?
Mesmos quatro arquivos, endpoints reais. Antes da saída do run, esta seção descreve o que um processo Locust realmente coloca no fio quando lançado.
Modelo de carga de trabalho
Dezessete usuários virtuais divididos em quatro grupos, cada VU escolhendo ferramentas dos pesos @task de seu servidor, ritmados pelo tempo de pensamento modelado para agente (80% pausas curtas intra-turn 0,1–0,5 s, 20% pausas inter-turn 5–30 s).
Figura 12. Um processo Locust → quatro servidores MCP. As barras de cada cartão são os pesos @task por ferramenta dentro da classe User.
Validação local
Antes de enviar a carga de trabalho para a nuvem, execute em um processo Python localmente. locust.conf fixa as configurações (e é o mesmo arquivo que o Azure Load Testing consome 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
Em seguida, lance uma execução local curta:
$ locust --config locust.conf --run-time 2m \
--csv results/local-2m --html results/local-2m.html
Útil para pegar uma asserção quebrada ou um token expirado em dois minutos em vez de quinze. O run principal relatado neste post é a execução em caminho de nuvem submetida ao Azure Load Testing.
Execução na nuvem
Mesma raiz do projeto, mesmo multi.py, mesmo locust.conf. Envie o YAML, depois inicie o 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
O Azure Load Testing sobe um engine, puxa os segredos do Key Vault via sua identidade gerenciada, faz fan-out para os quatro servidores MCP e escreve engine1_results.csv de volta no blob storage.
O que o run de 15 minutos produziu?
Esses números vêm de um run de 15 minutos em um único engine do Azure Load Testing em 2026-05-09, com 17 usuários virtuais divididos entre quatro servidores. Trate-os como uma linha de base em carga leve, não um teto de capacidade.
O run principal foi concluído em 15 minutos com veredito PASSED. Verifique o status e o resultado com 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"
}
O run enviou 2.293 requisições e registrou 3 falhas (0,13%). Discriminado por 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
Assinaturas de latência por servidor
O Azure Load Testing agrupa um dashboard autônomo com cada execução: gráficos, explorador de erros, séries temporais por minuto. Os diagramas resumidos abaixo destilam isso em um gráfico por servidor. Barra azul = avg, extensão laranja = avg → p90, label = avg / p90 em milissegundos.
Faça o download do dashboard localmente com 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
O dashboard do run relatado aqui está ao vivo em kroy92.github.io/azure-load-test-mcp-server/sample-report (ou explore os arquivos fonte no GitHub).
Figura 13. Microsoft Learn: API de docs pública, sem auth. O mais rápido dos quatro observados neste run.
Observação. Ambas as ferramentas de busca de docs ficam abaixo de 900 ms p90 com zero erros, consistente com um índice de busca cacheado e servido via CDN para tráfego anônimo.
Figura 14. GitHub: autenticado com PAT. search_code é bimodal; a cauda lenta e as únicas falhas do run parecem consistentes com backoff de rate limit.
Observação. search_code tem média de 554 ms mas p90 de 1,56 s com 3 erros, consistente com limitação de taxa da API do GitHub na cauda lenta.
Figura 15. Context7: recuperação de documentos é trabalho genuinamente pesado. O nível mais lento neste run, mas previsível.
Observação. Ambas as chamadas de ferramenta ficam acima de 1 s de média, consistentes com uma busca semântica síncrona / embedding lookup em cada requisição, sem camada de cache.
Figura 16. Azure DevOps Remote: autenticado com Entra, bearer por requisição. O servidor mais consistente neste run, com dispersão p90 apertada.
Observação. O handshake initialize tem 335 ms, o mais pesado dos quatro, consistente com validação de token bearer do Entra em cada nova sessão.
O que os números sugerem?
Três níveis de latência
Os quatro servidores caem em três níveis aproximados neste run. Microsoft Learn é o mais rápido (sub-900 ms p90 em ambas as ferramentas), o que pode refletir entrega de conteúdo cacheado e anônimo. O Azure DevOps Remote MCP é o mais consistente (um gap apertado entre média e p90 em todas as cinco ferramentas), o que parece consistente com um serviço interno estável apoiado por consultas determinísticas. Context7 é o mais lento (mais de 1 s em cada chamada), consistente com um embedding lookup síncrono por requisição. GitHub fica no meio, mas search_code é bimodal, com uma razão média/p90 de 2,8× e as únicas falhas do run, que podem refletir limitação de taxa no nível da API.
O handshake não é gratuito
O custo de cold session varia dez vezes entre os quatro servidores neste run, de 44 ms (Learn tools/list) a 335 ms (ADO initialize). Para agentes que abrem e fecham muitas sessões curtas, essa sobrecarga importa. O resultado argumenta a favor da reutilização de sessão onde possível, embora isso seja uma propriedade do runtime do agente, não do MCP em si.
Limitações
- Run com engine único. O run relatado usa uma instância de engine do Azure Load Testing. Concorrência maior (engineInstances > 1) não foi exercitada neste post e seria necessária para caracterizar comportamento de saturação.
- Contagem modesta de VUs. Dezessete usuários virtuais é uma carga de fumaça, não um teste de estresse. Os números relatados são uma linha de base em carga leve, não um teto de capacidade.
- Endpoints de terceiros não reproduzíveis. Três dos quatro servidores são serviços externos com seus próprios rate limits, termos de serviço e variabilidade operacional. Os números relatados aqui descrevem o estado desses serviços na data do run e não devem ser tratados como compromissos de nível de serviço.
- Apenas tools. O harness exercita
tools/callexclusivamente. Recursos e prompts usam o mesmo formato de wire mas não foram medidos.
Uma nota operacional
Nota operacional. Testes de carga em servidores MCP hospedados de terceiros devem ser feitos com permissão e dentro dos rate limits publicados pelo operador. Os alvos usados neste post são endpoints públicos; a carga de trabalho é deliberadamente pequena. Se você replicar este trabalho contra serviços que não possui, revise os termos de serviço primeiro.
Conclusão
Você pode construir um harness de teste de carga fiel ao protocolo para servidores MCP hospedados em algumas centenas de linhas de Python sobre Locust. As principais escolhas de design (uma divisão stateful/stateless na classe base, um hook de auth que suporta tokens estáticos e dinâmicos, e um leitor de resposta que lida com application/json e text/event-stream) tornam o harness portátil através dos servidores MCP hospedados exercitados neste estudo e provavelmente através da maioria dos outros com a mesma forma.
Use este harness como linha de base para seus próprios servidores MCP hospedados: comece com um servidor localmente para validar o comportamento do wire, depois submeta o mesmo YAML para Azure Load Testing para executá-lo sob carga gerenciada com gates de pass/falha via failureCriteria. Os mesmos arquivos de projeto rodam sem modificação de um laptop de desenvolvedor e de engines do Azure Load Testing na nuvem, com segredos resolvidos através do Key Vault por identidade gerenciada em tempo de execução. O run relatado aqui revela assinaturas de latência distintas e interpretáveis em quatro servidores de produção e mostra que o harness é adequado tanto para verificações de fumaça em desenvolvimento quanto para gates de pipeline de release. A partir daí, as extensões óbvias são as primitivas de resources e prompts, perfis de carga ramp-and-soak e fan-out multi-engine para caracterização de saturação.
Saiba mais
- Model Context Protocol Specification, Anthropic, version 2025-06-18.
- Locust documentation, o framework de teste de carga sobre o qual este harness é construído.
- Azure Load Testing documentation, o serviço gerenciado de teste de carga no Azure.
Obtenha o código
O harness completo (_base.py, as quatro classes de usuário por servidor, multi.py, locust.conf e o YAML do Azure Load Testing) vive em kroy92/azure-load-test-mcp-server no GitHub. Clone o repo, instale com uv, e você pode executar o mesmo multi.py mostrado neste post localmente antes de enviar o YAML para o Azure Load Testing.
Built with Locust 2.43, Python 3.13, and uv.
Artigo originalmente publicado por Krishna-Roy (Microsoft) em Azure Updates - Latest from Azure Charts.
Perguntas Frequentes
-
Por que o tráfego de agentes de IA é diferente do tráfego humano?
Agentes operam em bursts, mantêm sessões longas e chamam as mesmas ferramentas repetidamente com um paralelismo que um cliente API típico não gera. Por isso, testes de carga tradicionais não representam o comportamento real de servidores MCP. -
Quais padrões de autenticação o harness suporta?
Três: nenhum (Microsoft Learn), bearer estático via variável de ambiente (GitHub) e token dinâmico por requisição (Azure DevOps Remote MCP usando Microsoft Entra ID). A classe base oferece hooks simples para cada caso. -
Posso usar o mesmo harness localmente e no Azure Load Testing?
Sim. O projeto usa uma estrutura flat de arquivos (sem empacotamento) que sobe para Azure Load Testing via YAML. As mesmas classes de usuário, configurações e segredos do Key Vault funcionam em ambos os ambientes sem modificação. -
O que a execução de 15 minutos revelou sobre os servidores testados?
Três tiers de latência: Microsoft Learn (mais rápido, sub-900 ms p90), GitHub (médio com bimodalidade em search_code), Context7 (mais lento, >1 s, por embedding síncrono). O handshake inicial variou de 44 ms a 335 ms, mostrando que o custo de cold session não é desprezível. -
Quais limitações o artigo aponta?
Single engine run (sem caracterização de saturação), carga leve (17 usuários virtuais), endpoints de terceiros com rate limits e variabilidade, e foco apenas em tools/call, sem medir resources e prompts.