Docs›IA & MCP›Servidor MCP
IA & MCP

Servidor MCP

mcp.config.json, tools crud/query/custom, permissão piso+matriz, escopo de linha, PII e auditoria.

Servidor MCP

O MAD framework expõe um servidor MCP (Model Context Protocol) dirigido por manifest: em vez de escrever uma classe Tool em PHP para cada operação, você descreve as entidades, os campos e as regras de acesso num único arquivo mcp.config.json — o runtime lê esse arquivo a cada request e gera as tools dinamicamente. Um agente de IA (Claude Desktop, Claude Code, ou o Copilot embutido do próprio MAD — veja AI Copilot) se conecta nesse servidor e enxerga um conjunto de tools de CRUD/consulta/ação sobre as tabelas do seu app, sempre passando pelo sistema de permissões já existente (IAM), com PII mascarada, escopo por usuário/unidade e auditoria automática.

Implementação: Mad\Mcp\McpManifestServer, construído sobre o pacote oficial laravel/mcp (Laravel\Mcp\Server). Transporte: HTTP + SSE (JSON-RPC 2.0), não stdio — o servidor é só mais uma rota Laravel.

Habilitando

// config/mad.php
'mcp' => [
    'enabled'         => (bool) env('MAD_MCP_ENABLED', false), // off por padrao
    'manifest_path'   => env('MAD_MCP_MANIFEST', ''),          // default: mcp.config.json na raiz
    'database'        => '',                                    // conexao default das tools
    'admin_program'   => 'ProgramForm',                         // IamProgram que define "admin" p/ o MCP
    'token_ttl_days'  => '365',
    'public_base_url' => '',                                    // M4: base canonica do endpoint (ignora X-Forwarded-*)

    // Escopo de linha (sub-flag) — off = gateway legado, sem filtro por linha.
    'row_scope_enabled'        => (bool) env('MAD_MCP_ROW_SCOPE_ENABLED', false),
    'row_scope_bypass_program' => env('MAD_MCP_ROW_SCOPE_BYPASS_PROGRAM', ''), // nivel 'all'
    'row_scope_unit_program'   => env('MAD_MCP_ROW_SCOPE_UNIT_PROGRAM', ''),   // nivel 'unit'

    // Guarda de escrita — off por padrao (veja "Guarda de escrita").
    'write_requires_admin'     => (bool) env('MAD_MCP_WRITE_REQUIRES_ADMIN', false),
    'write_requires_approval'  => (bool) env('MAD_MCP_WRITE_REQUIRES_APPROVAL', false),
],

Com a flag desligada, routes/ai.php nem registra a rota:

// routes/ai.php — lido pelo McpServiceProvider do laravel/mcp
use Mad\Mcp\McpManifestServer;

if (! config('mad.mcp.enabled')) {
    return;
}

Mcp::web('/mcp/v1/sse', McpManifestServer::class)
    ->middleware([App\Http\Middleware\McpManifestAuthMiddleware::class]);

A resolução do manifesto (McpManifestLoader::resolvePath()) tenta, em ordem: [mcp] manifest_path → base_path('mcp.config.json') → base_path('app/config/mcp.config.json'). O arquivo é recarregado automaticamente quando muda (cache em processo por path:mtime) — não precisa de deploy para uma edição de manifesto valer.

Autenticação

O cliente MCP se autentica com Authorization: Bearer mcp_<prefixo>_<token>. McpManifestAuthMiddleware resolve o token via McpTokenService (tabela mad_mcp_token), carrega o usuário, popula a sessão com as permissões dele (AuthenticationService::loadSessionVars) e preenche McpCurrentUser — toda decisão de permissão e escopo a partir daí lê a identidade desse holder estático por processo, nunca de um parâmetro vindo do agente.

php artisan tinker --execute='echo App\Service\Mcp\McpTokenService::issue("admin", "minha-app", "mcp_business_").PHP_EOL;'

App\Service\Mcp\McpTokenService é o emissor único (tabela mad_mcp_token na conexão iam, criada sob demanda com CREATE TABLE IF NOT EXISTS; TTL de [mcp] token_ttl_days, default 365 dias). Cada superfície tem seu par slug + prefixo — emissão e revogação precisam usar o mesmo par, senão a revogação de um lado não enxerga o token do outro:

Superfície Slug (app_slug) Prefixo Emissor
Copilot embed copilot mcp_embed_ issueEmbed($login) (no login)
Central de Comando agent_console mcp_admin_ issueAgentConsole($login)
Cliente MCP externo livre (do manifesto) auth.token_prefix issue($login, $slug, $prefix)

listByUser() / revoke() alimentam a tela de tokens; endpointUrl() monta a URL canônica do endpoint (respeitando [mcp] public_base_url).

Tentativas de autenticação falhas são rate-limitadas por IP (McpRateLimiter) — 429 Too Many Requests com Retry-After: 600 depois de repetidas falhas.

O manifesto mcp.config.json

Exemplo real (trecho de um MiniERP de demonstração):

{
    "version": "1.0.0",
    "database": "business",
    "endpoint": "http://localhost:8911/mcp/v1/sse",
    "system": {
        "name": "MiniERP MCP",
        "description": "Acesso MCP ao MiniERP de demonstracao.",
        "domain": "ERP",
        "lang": "pt-BR",
        "tone": "objetivo"
    },
    "auth": { "strategy": "mapped_to_system_user", "token_prefix": "mcp_business_" },
    "fields": {
        "teste_cliente": [
            { "name": "id", "type": "integer", "pk": true, "expose": true, "desc": "ID do cliente" },
            { "name": "nome", "type": "varchar(120)", "expose": true, "required": true, "desc": "Nome do cliente" },
            { "name": "email", "type": "varchar(120)", "expose": true, "pii": true, "desc": "Email (PII)" },
            { "name": "cidade", "type": "varchar(80)", "expose": true, "desc": "Cidade" },
            { "name": "uf", "type": "varchar(2)", "expose": true, "desc": "UF" }
        ]
    },
    "scope": {
        "teste_cliente": { "exempt": true }
    },
    "namespaces": [
        {
            "ns": "clientes",
            "label": "Clientes",
            "tools": [
                { "id": "list_clientes", "kind": "crud", "verb": "list", "entity": "teste_cliente", "program": "ProgramForm", "description": "Lista clientes com filtros, ordenacao e limite." },
                { "id": "read_cliente", "kind": "crud", "verb": "read", "entity": "teste_cliente", "program": "ProgramForm", "description": "Le um cliente pelo ID." },
                { "id": "create_cliente", "kind": "crud", "verb": "create", "entity": "teste_cliente", "program": "ProgramForm", "description": "Cria um cliente novo." }
            ]
        }
    ],
    "glossary": [
        { "term": "cliente", "definition": "Registro da tabela teste_cliente." }
    ],
    "fewshot": [
        { "q": "quantos clientes temos em SP?", "tools": ["list_clientes"] }
    ]
}

Chaves de topo:

Chave Papel
version / database / endpoint metadados — database é a conexão que as tools usam por padrão
system nome/descrição/domínio/idioma/tom — viram o system prompt do servidor (junto com glossary e fewshot)
auth estratégia de autenticação (hoje só mapped_to_system_user) e o prefixo de token (token_prefix)
fields por tabela exposta: nome, tipo, pk, expose, pii, required, enum — controla schema e mascaramento (veja PII)
scope por tabela: owner_column / unit_column / exempt — contrato de escopo de linha (veja Escopo de linha)
namespaces agrupamento de tools (ns/label) — cada tool dentro vira uma instância de McpCrudTool, McpQueryTool ou McpCustomTool
permissions matriz {grupo_id: {tool_id: 'allow'|'deny'}} — veja Permissão
glossary / fewshot vocabulário e exemplos de pergunta→tool injetados no prompt

Tipos de tool

Cada entrada de namespaces[].tools[] tem um kind: crud (default), query ou custom. McpToolFactory::build() instancia a classe certa por spec.

crud

Uma instância por verbo habilitado de uma entidade — ou seja, uma entidade totalmente exposta normalmente tem 5 specs no manifesto (list, read, create, update, del), todas com o mesmo entity.

Verbo Efeito
list filtros (igualdade, só colunas expostas), ordenacao, colunas (subconjunto), limite (default 50) → {total, rows}
read busca por PK → linha mascarada
create valida campos expostos (required, enum, coerção de tipo), roda em transação, audita
update atualização parcial dos campos enviados, roda em transação, audita
del sempre exige confirm=true no request, roda em transação, audita

create/update/del passam por McpWriteGuard::assertAllowed() antes de qualquer escrita (veja Guarda de escrita).

query

Uma consulta salva, somente leitura: colunas/filtros/ordenação/limite são fixos no manifesto (o que o configurador aprovou); params declara quais viram filtros dinâmicos de igualdade:

{
    "id": "pedidos_por_cliente",
    "kind": "query",
    "entity": "teste_pedido",
    "params": ["cliente_id", "status?"],
    "query": {
        "columns": ["id", "numero", "valor_total", "status"],
        "filters": [["cancelado", "=", "0"]],
        "order": "data_pedido desc",
        "limit": 100
    }
}

cliente_id é obrigatório (sem ?); status é opcional. Todo bind é parametrizado — nunca SQL concatenado.

custom

Invoca um método PHP real, cujo class/method vem do manifesto (o configurador é a fonte confiável, não o LLM):

{
    "id": "recalcular_comissao",
    "kind": "custom",
    "class": "App\\Service\\Vendas\\ComissaoService",
    "method": "recalcular",
    "params": ["pedido_id:integer", "ator:scope"],
    "confirm": true
}

params[] aceita "nome", "nome?" (opcional) e "nome:tipo" (coage o argumento). Dois tipos especiais — scope e actor — nunca vêm do request: o servidor injeta o id do usuário do token (McpScopeContext::userId()) nessa posição, ignorando qualquer valor do LLM, e o parâmetro nem aparece no JSON Schema exposto ao modelo.

Tool custom é o ponto mais perigoso do manifesto (SQL opaco, fora do gateway escopado), por isso tem três portões extras:

  1. Allowlist fail-closed — [mcp] custom_tools_allowed[] precisa listar Classe::metodo explicitamente; lista vazia/ausente nega tudo.
  2. Admin-only por padrão — só admin chama, a menos que o spec marque "reviewed": true (opt-out explícito após revisão de segurança do método).
  3. Confirm obrigatório, salvo "read_only": true.

O resultado é mascarado pelo McpPiiMasker da entidade declarada (entity), se houver, antes de voltar ao agente.

Permissão: piso + matriz

McpPermissionResolver::denyReason() decide em camadas — a matriz nunca concede além do piso:

if (! McpCurrentUser::isAuthenticated())        return 'unauthenticated';
if (! $this->floorAllows($spec))                return 'piso';   // 1
if ($this->matrixDecision($manifest, $id)
        === 'deny')                             return 'matriz'; // 2
if (! empty($spec['confirm']) && ! $this->isAdmin())
                                                 return 'admin';  // 3
  1. Piso — todo tool spec precisa de um program (um IamProgram do sistema de permissões já existente). PermissionGate::canAccess($program) decide. Default-deny: tool sem program mapeado fica fechada até ser configurada — nunca libera "qualquer usuário autenticado" por omissão.
  2. Matriz — bloco opcional permissions[<grupo_id>][<tool_id>] = 'allow'|'deny' no manifesto, agregado sobre todos os grupos do usuário: deny vence se qualquer grupo negar; senão allow se algum grupo permitir; senão herda o piso. A matriz só restringe o piso ou confirma um allow dentro dele — nunca abre uma tool que o piso já não permitia.
  3. Tools destrutivas (confirm: true no spec) exigem admin além de piso+matriz — isAdmin() checa acesso ao [mcp] admin_program (default ProgramForm).

Tool negada não aparece em tools/list (shouldRegister() filtra na descoberta) — o agente nem sabe que ela existe. Uma chamada tentada numa tool negada (tools/call) é auditada explicitamente como "permissão negada" em vez de cair no genérico "tool not found" — isso é o que dá sinal pra detectar tentativa de abuso.

Escopo de linha

O escopo de linha é opt-in por install: [mcp] row_scope_enabled (env MAD_MCP_ROW_SCOPE_ENABLED) vem desligado, e com ele desligado o MCP usa o gateway legado (nenhum filtro por linha, comportamento anterior). Ligado, o filtro passa a ser por usuário e fail-closed: entidade exposta sem bloco scope (e sem exempt) é negada.

Com [mcp] row_scope_enabled ligado, cada tabela exposta também é filtrada por linha — usuário A não vê linha de usuário B. Isso é um eixo ortogonal ao multi-tenant por tenant_id do modo pool (que filtra por empresa em todo o Eloquent); o escopo MCP filtra por usuário/unidade e age só no caminho do agente.

Bloco scope por tabela:

"scope": {
  "teste_pedido":        { "owner_column": "created_by_user_id", "unit_column": "created_by_unit_id" },
  "teste_pedido_item":   { "unit_column": "created_by_unit_id" },
  "teste_estado_pedido": { "exempt": true }
  // teste_tarefa SEM bloco → exposta por uma tool = DENY (fail-closed)
}

Nível do usuário (McpGrantResolver::userLevel()), do mais restrito ao mais amplo:

Nível Como é concedido
own (default) nenhum dos dois programas abaixo
unit acesso ao [mcp] row_scope_unit_program
all acesso ao [mcp] row_scope_bypass_program — leitura sem filtro, auditada como "god-read"

Toda tabela exposta precisa declarar owner_column, unit_column ou exempt: true — sem isso o runtime nega tudo por padrão (fail-closed). McpScopedGateway é o único chokepoint de construção de gateway: ele aplica o WHERE forçado em select/count/find, reaplica o predicado de soft-delete (o caminho do agente é PDO cru, não passa pelo global scope do Eloquent), carimba dono/unidade no insert a partir da identidade do token (nunca do payload do LLM) e nega update/delete de linha fora do escopo.

php artisan mad:mcp:lint-scope                 # CI gate: valida scope contra o schema REAL
php artisan mad:mcp:lint-scope --manifest=/caminho/alternativo/mcp.config.json

Detalhe completo da árvore de decisão (qual coluna é "dono", quais nomes são sempre auditoria e nunca dono, etc.) está no contrato interno do gerador — fora do escopo desta página de usuário, mas o ponto prático é: se uma tabela de negócio não tem coluna de dono e não é referência/lookup, ela fica fechada até alguém decidir (adicionar a coluna ou marcar exempt).

Mascaramento de PII

Cada campo do manifesto tem expose (aparece na resposta?) e pii (precisa mascarar?). McpPiiMasker aplica isso em toda linha que sai de uma tool (list/read/create/update e no resultado de tools custom):

  • Campo com "expose": false é removido da resposta inteiramente.
  • Campo com "pii": true é mascarado: e-mail vira local@*** (mantém a parte antes do @); qualquer outro valor vira ***.
// fields.teste_cliente tem email com "pii": true
{ "id": 5, "nome": "Construtora Horizonte", "email": "contato@***", "cidade": "Campinas", "uf": "SP" }

Guarda de escrita

McpWriteGuard é read-only-by-default: dois portões independentes, ambos config-gated e off por padrão (para não quebrar tools de escrita já existentes — um install liga conforme precisar de mais rigor):

'write_requires_admin'    => (bool) env('MAD_MCP_WRITE_REQUIRES_ADMIN', false),
'write_requires_approval' => (bool) env('MAD_MCP_WRITE_REQUIRES_APPROVAL', false),
  • write_requires_admin — só admin ([mcp] admin_program) pode chamar create/update.
  • write_requires_approval — exige confirm=true explícito no request (pensado para vir de uma UI humana, nunca do próprio LLM).

del sempre exige confirm=true, independente dessas flags — é hardcoded em McpCrudTool::doDelete().

Auditoria

Três trilhas independentes, todas best-effort (nunca derrubam a operação por falha de log):

Classe Quando grava Onde
McpChangeAudit toda escrita/destrutiva (insert/update/delete/custom) — uma linha por coluna alterada em update, valores antes/depois mad_log_change (conexão log), class_name = 'MCP'
McpAccessAudit toda tentativa negada por permissão error_log() + APM (se presente) + log de acesso (SystemAccessLogService::registerMcpDenied)
McpScopeAudit leituras (query) e bypass de escopo (all), só quando row_scope_enabled está ligado reusa mad_log_change com operation='query'/'scope-bypass'

Conectando um cliente MCP

Qualquer cliente MCP compatível com HTTP+SSE (Claude Desktop, Claude Code, etc.) aponta para o endpoint com o Bearer token:

{
  "mcpServers": {
    "minha-app": {
      "url": "https://minha-app.exemplo.com/mcp/v1/sse",
      "headers": { "Authorization": "Bearer mcp_business_xxxxxxxx" }
    }
  }
}

tools/list retorna só as tools que o usuário do token enxerga (piso + matriz já aplicados); tools/call reaplica a mesma checagem e audita qualquer tentativa negada.

Veja também

  • AI Copilot (embed) — chama essas mesmas tools in-process (sem round-trip de rede) a partir do widget de chat.
  • Central de Comando (Agent Console) — agente separado, admin-only, que opera o próprio app (código/menu/permissões) em vez de dados de negócio.