Autorização no MCP: escopo por ferramenta com SMART on FHIR

Token com patient/Patient.r tenta criar Observation e toma 403 com scope= no header. São 36 linhas sobre o SDK MCP 1.27.0. Resultado: [401, 200, 403, 200].

Rogério Rodrigues

Rogério Rodrigues

Terminal com quatro chamadas de um agente a um servidor MCP com token patient/Patient.r: sem token 401, ler paciente 200, criar Observation 403 com insufficient_scope, ler paciente 200.

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.app não é API pública do SDK. Pode quebrar numa atualização. Por isso a versão está pinada no requirements.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.cruds cobre patient/Patient.r). Deixei de fora pra manter o código legível. Em produção isso é obrigatório.
  • Nada persiste. create_observation devolve 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_scopes do 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}}}'

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.

Gostou? O próximo teste sai primeiro no LinkedIn e no Instagram.

Se você põe dado pessoal brasileiro em IA no trabalho e tem uma dúvida, me escreve. O que der pra responder em público vira artigo aqui.