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:
- Allowlist fail-closed —
[mcp] custom_tools_allowed[]precisa listarClasse::metodoexplicitamente; lista vazia/ausente nega tudo. - 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). - 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
- Piso — todo tool spec precisa de um
program(umIamProgramdo sistema de permissões já existente).PermissionGate::canAccess($program)decide. Default-deny: tool semprogrammapeado fica fechada até ser configurada — nunca libera "qualquer usuário autenticado" por omissão. - 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ãoallowse algum grupo permitir; senão herda o piso. A matriz só restringe o piso ou confirma umallowdentro dele — nunca abre uma tool que o piso já não permitia. - Tools destrutivas (
confirm: trueno spec) exigem admin além de piso+matriz —isAdmin()checa acesso ao[mcp] admin_program(defaultProgramForm).
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 viralocal@***(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 chamarcreate/update.write_requires_approval— exigeconfirm=trueexplí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.