Dá pra fazer um servidor MCP recusar uma ferramenta específica pelo escopo do token. Na demo 05 do meu repositório de pesquisa, um agente com token patient/Patient.r lê o paciente (200) e, quando tenta criar uma Observation, toma HTTP 403 com WWW-Authenticate: Bearer error="insufficient_scope", scope="patient/Observation.c". Quatro chamadas, resultado [401, 200, 403, 200]. O SDK oficial em Python (mcp 1.27.0) não faz isso sozinho. A recusa por ferramenta são 36 linhas minhas.
Abaixo: por que isso não vem pronto, o código, o que saiu e onde a demo é fraca.
Qual é o problema da autorização no MCP?
O fluxo que a spec desenha em detalhe é o de uma pessoa clicando “permitir”. Leia o diagrama da spec de 2026-07-28: o cliente abre o navegador, o usuário autoriza, volta um código, troca por token. Funciona bem quando tem gente do outro lado.
Agente não clica. Ele recebe um token e chama ferramentas em loop. A pergunta que me interessa é outra: esse token pode chamar essa ferramenta? Num tutor de enfermagem, o agente pode ler o prontuário. Não pode escrever nele, diga o prompt o que disser. A recusa tem que vir do servidor. Prompt não é controle de acesso.
A spec já tem as peças. O servidor MCP é um resource server OAuth 2.1. A spec recomenda (SHOULD) que escopo insuficiente em tempo de execução vire 403 com error="insufficient_scope", scope= com o escopo que falta e resource_metadata. O que falta é o lugar pra pendurar isso por ferramenta.
O que o SDK Python do MCP já faz e o que não faz?
O SDK 1.27.0 tem TokenVerifier, AuthSettings e um middleware que devolve 401 com o ponteiro resource_metadata quando não vem token. Também tem required_scopes. Só que required_scopes vale pro servidor inteiro. É a porta da casa. Não existe gancho por ferramenta.
Tem um segundo detalhe. Quando o próprio SDK recusa por escopo, ele escreve o escopo faltante em prosa, dentro de error_description:
WWW-Authenticate: Bearer error="insufficient_scope", error_description="Required scope: patient/Patient.r", resource_metadata="..."
Sem o parâmetro scope=. A seção Scope Challenge Handling da spec diz que o servidor SHOULD mandar scope=. Um cliente que quer pedir mais permissão (o tal step-up) não consegue fazer parse de frase em inglês.
Como mapear ferramenta MCP pra escopo SMART on FHIR?
Eu reaproveitei a gramática de escopo do SMART App Launch v2: patient/<Recurso>.<cruds>, onde as letras são create, read, update, delete e search. Em vez de aplicar em endpoint FHIR, apliquei em ferramenta MCP. O MCP precisa de algo parecido com o que o SMART on FHIR fez pros apps de saúde, mas essa discussão fica pra outro texto.
O mapa é a ideia inteira:
TOOL_SCOPES = {
"read_patient": "patient/Patient.r",
"create_observation": "patient/Observation.c",
}
Dois tokens sintéticos. O do tutor tem só leitura. O do pipeline tem leitura e criação de Observation:
TOKENS = {
"tok-reader": AccessToken(token="tok-reader", client_id="nursia-tutor", scopes=["patient/Patient.r"]),
"tok-writer": AccessToken(
token="tok-writer", client_id="nursia-pipeline", scopes=["patient/Patient.r", "patient/Observation.c"]
),
}
O gate de 36 linhas
É um middleware ASGI. Lê o corpo JSON-RPC, vê se é tools/call, acha o nome da ferramenta, confere o escopo. Se faltar, responde 403 antes da ferramenta rodar. Se não, devolve o corpo intacto pro SDK seguir.
class ToolScopeGate:
"""HTTP 403 insufficient_scope per tool, as the MCP spec says a server SHOULD.
The SDK only knows required_scopes for the whole server. This reads the
JSON-RPC body, finds tools/call, and refuses before the tool runs.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
user = scope.get("user")
if scope["type"] != "http" or scope["method"] != "POST" or not isinstance(user, AuthenticatedUser):
return await self.app(scope, receive, send) # 401 is the SDK's job
chunks, more = [], True
while more:
msg = await receive()
chunks.append(msg.get("body", b""))
more = msg.get("more_body", False)
body = b"".join(chunks)
try:
rpc = json.loads(body)
except ValueError:
rpc = {}
needed = TOOL_SCOPES.get(rpc.get("params", {}).get("name")) if rpc.get("method") == "tools/call" else None
if needed and needed not in user.scopes:
www = f'Bearer error="insufficient_scope", scope="{needed}", resource_metadata="{METADATA}"'
payload = json.dumps({"error": "insufficient_scope", "scope": needed}).encode()
await send({"type": "http.response.start", "status": 403,
"headers": [(b"content-type", b"application/json"), (b"www-authenticate", www.encode())]})
return await send({"type": "http.response.body", "body": payload})
async def replay():
return {"type": "http.request", "body": body, "more_body": False}
await self.app(scope, replay, send)
Repara na primeira condição. Sem usuário autenticado, o gate não faz nada. O 401 continua sendo trabalho do SDK. Divisão clara: o SDK cuida de quem é você, o gate cuida do que você pode chamar.
Onde o gate entra
Aqui está a parte feia. Como o SDK não expõe gancho, eu insiro o gate dentro da rota /mcp depois que streamable_http_app() monta a aplicação. Assim ele roda depois do middleware de autenticação, com o usuário já resolvido:
app = mcp.streamable_http_app()
for route in app.routes: # insert the per-tool gate inside the SDK's auth middleware
if isinstance(route, Route) and route.path == "/mcp":
route.app = ToolScopeGate(route.app)
O que saiu?
Rodei em 15/09/2026 com Python 3.11 e mcp 1.27.0. Quatro chamadas tools/call:
| # | Ferramenta | Escopos do token | Resultado |
|---|---|---|---|
| 1 | read_patient | nenhum (sem token) | 401, com ponteiro resource_metadata |
| 2 | read_patient | patient/Patient.r | 200, Patient sintético |
| 3 | create_observation | patient/Patient.r | 403, scope="patient/Observation.c" |
| 4 | create_observation | patient/Patient.r patient/Observation.c | 200, Observation sintética |
Duas recusas em quatro. Uma por falta de token, uma por escopo errado. A linha 3 é a que importa. O tutor leu o prontuário e não conseguiu escrever nele. O header da recusa:
HTTP/1.1 403 Forbidden
www-authenticate: Bearer error="insufficient_scope", scope="patient/Observation.c", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"
{"error": "insufficient_scope", "scope": "patient/Observation.c"}
Com scope= no lugar certo, um cliente sabe exatamente o que pedir no step-up. Sem parse de prosa.
Limites da demo
- Gancho frágil. Mexer em
route.appnão é API pública do SDK. Pode quebrar numa atualização. Por isso a versão está pinada norequirements.txt. - Token de mentira. São strings fixas. Sem login, sem consentimento, sem emissão. Num servidor de verdade, quem emite o token é um authorization server, que pode até rodar junto, e o servidor MCP só valida.
- Só HTTP. A spec de autorização vale pra transporte HTTP. Em stdio, a credencial vem do ambiente e nada disso roda.
- Sem hierarquia de escopo. A spec diz que o servidor MUST considerar que um escopo amplo implica os estreitos (
patient/Patient.crudscobrepatient/Patient.r). Deixei de fora pra manter o código legível. Em produção isso é obrigatório. - Nada persiste.
create_observationdevolve um recurso sintético e esquece. - O gate lê o corpo. Só pra achar o nome da ferramenta, sem logar nada. Mas é um ponto a mais que toca o payload, e payload de saúde tem dado pessoal.
Quando não usar isso
- Servidor local via stdio, rodando na máquina do próprio usuário. Não tem token pra checar.
- Quando todas as ferramentas exigem a mesma permissão. Aí
required_scopesdo SDK resolve sem gambiarra. - Quando o escopo depende do argumento, não da ferramenta. Exemplo: ler só pacientes da própria unidade. Mapa ferramenta para escopo não cobre isso. Você precisa de regra no backend FHIR.
- Se o seu SDK ou gateway já oferece autorização por ferramenta. Use o que é suportado e jogue este gate fora.
Se o seu receio é dado pessoal saindo pelo modelo, e não ferramenta errada sendo chamada, o assunto é outro. Escrevi sobre isso em A IA achou o CPF. E o CPF vazou mesmo assim.
Fonte e como reproduzir
Código, testes e headers crus estão na demo 05 do nursia-research-lab, parte do projeto NursIA (PPGINFOS/UFSC, bolsa FAPESC). Dado 100% sintético. Licença MIT.
git clone https://github.com/rogeriorrodrigues/nursia-research-lab
cd nursia-research-lab/demos/05-mcp-scopes-smart-on-fhir
pip install -r requirements.txt
python3 -m pytest tests -q -s
python3 demo.py
Saída esperada: statuses=[401, 200, 403, 200] refused=2/4 (1x401 no token, 1x403 wrong scope). Pra ver o 403 na mão, suba python3 server.py e rode:
curl -si http://127.0.0.1:8000/mcp \
-H "Authorization: Bearer tok-reader" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_observation","arguments":{"patient_id":"p1","loinc":"8867-4","value":72}}}'
- MCP specification 2026-07-28, Authorization (Roles, Error Handling, Scope Challenge Handling)
- MCP Python SDK, módulo
mcp.server.auth - SMART App Launch, Scopes and Launch Context
- RFC 6750, seção 3.1 (insufficient_scope) e RFC 9728 (Protected Resource Metadata)
In English
MCP’s Python SDK (1.27.0) only checks scopes server-wide. A 36-line ASGI gate maps each MCP tool to a SMART on FHIR v2 scope and returns 403 with scope= in WWW-Authenticate, as the 2026-07-28 spec says a server SHOULD. A read-only agent token gets [401, 200, 403, 200] across four calls. Synthetic data, code on GitHub.
Última revisão em 07/10/2026.

