
Read-only não é uma política de segurança
Marcar uma tool como read-only é útil. Diz ao cliente que ela não deveria alterar o mundo.
Mas o rótulo não valida entrada, não decide quem pode chamar, não limita volume e não impede que uma mudança futura acrescente uma escrita atrás da mesma interface.
Em MCP, essa diferença é explícita: anotações descrevem o comportamento da tool, e os clientes devem tratá-las como não confiáveis quando o servidor não é confiável. A própria especificação também exige que o servidor valide entradas, aplique controle de acesso, limite invocações e sanitize saídas. Não é uma recomendação de estilo; é a fronteira do protocolo.
A tese prática é simples: read-only é uma propriedade da operação. Segurança é um conjunto de controles verificáveis em volta dela.
O contrato vem antes do agente
Uma tool pública recente deste site ilustra a separação. Ela expõe uma única operação, collections.analyze_portfolio, para receber uma carteira fornecida pelo operador e devolver uma fila de prioridade. O contrato declara o que entra, o que sai e o que a tool não faz. O trecho do contrato, no commit de publicação:
const invoiceSchema = z.object({
id: z.string().min(1),
customerId: z.string().min(1),
customerName: z.string().min(1),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
amountCents: z.number().int().positive(),
status: z.enum(["open", "paid", "void"]),
doNotContact: z.boolean(),
});
const analysisSchema = z.object({
totalOpenCents: z.number().int().nonnegative(),
accounts: z.array(
z.object({
customerId: z.string(),
customerName: z.string(),
amountCents: z.number().int().positive(),
oldestDueDate: z.string(),
daysOverdue: z.number().int().nonnegative(),
priority: z.enum(["high", "medium"]),
recommendedAction: z.enum(["human_review", "prepare_reminder"]),
reason: z.string(),
}),
),
});
function createMcpServer(): McpServer {
const server = new McpServer({
name: "cobranca-inteligente",
version: "0.1.0",
});
server.registerTool(
"collections.analyze_portfolio",
{
title: "Analisar carteira de cobrança",
description:
"Use when a finance operator explicitly provides a small invoice portfolio and wants a read-only, explainable collection priority queue. It only analyzes the supplied invoices; it never sends messages, accesses a CRM, changes records, or makes payment decisions.",
inputSchema: {
asOf: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
invoices: z.array(invoiceSchema).min(1).max(500),
},
outputSchema: analysisSchema.shape,
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
},
async (input) => {
const analysis = analyzePortfolio(input);
return {
content: [
{
type: "text",
text: `Carteira analisada: ${analysis.accounts.length} conta(s) prioritária(s), sem ações externas.`,
},
],
structuredContent: { ...analysis },
};
},
);
return server;
}
/** MCP stateless: o processo recebe somente o payload desta request e não o persiste. */
export async function POST(request: Request): Promise<Response> {
const server = createMcpServer();
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
await server.connect(transport);
return transport.handleRequest(request);
}A entrada não é “qualquer JSON que o modelo conseguir montar”. Ela define, entre outras coisas:
- uma data de referência;
- de uma a 500 faturas por chamada;
- status fechado em
open,paidouvoid; - valor inteiro positivo em centavos;
- uma flag
doNotContact.
A saída também é contrato: total aberto, contas priorizadas, data mais antiga em atraso, prioridade e ação recomendada. A especificação de tools explica por que um outputSchema importa: ele permite validação e dá ao cliente uma estrutura para tratar a resposta sem depender de texto livre.
Isso reduz ambiguidade. Não substitui controle de segurança.
Limite de payload não é rate limit
O limite de 500 itens é uma defesa útil contra uma chamada desproporcional. Ainda assim, ele não diz quantas chamadas alguém pode fazer por minuto, por origem ou por identidade.
São problemas diferentes:
limite de payload → quanto trabalho uma chamada provoca
rate limit → quantas chamadas um ator pode provocar
controle de acesso → quem pode provocar chamadasConfundir essas camadas produz uma falsa sensação de segurança. Um schema perfeito continua aceitando uso abusivo se qualquer pessoa puder chamá-lo sem limite. Um rate limit bem configurado continua permitindo uma ação indevida se a autorização estiver errada. E autorização não corrige uma saída que devolve mais dados do que a interface deveria revelar.
A especificação do MCP lista as quatro obrigações do servidor juntas por um motivo: validar entrada, controlar acesso, limitar invocações e sanitizar saída são controles complementares, não alternativas entre si.
A decisão operacional precisa aparecer no resultado
O algoritmo da ferramenta separa fatos da próxima ação. Ele considera apenas faturas abertas, remove da fila clientes marcados com doNotContact e recomenda human_review ou prepare_reminder conforme a prioridade. A implementação:
/** Agrupa somente faturas abertas e libera uma fila explicável, sem efeitos externos. */
export function analyzePortfolio(
input: AnalyzePortfolioInput,
): PortfolioAnalysis {
const blockedCustomerIds = new Set(
input.invoices
.filter((invoice) => invoice.doNotContact)
.map((invoice) => invoice.customerId),
);
const openInvoices = input.invoices.filter(
(invoice) =>
invoice.status === "open" && !blockedCustomerIds.has(invoice.customerId),
);
const byCustomer = new Map<string, Invoice[]>();
for (const invoice of openInvoices) {
const customerInvoices = byCustomer.get(invoice.customerId) ?? [];
customerInvoices.push(invoice);
byCustomer.set(invoice.customerId, customerInvoices);
}
const accounts = [...byCustomer.entries()]
.map(([customerId, invoices]) => {
const overdueInvoices = invoices.filter(
(invoice) => invoice.dueDate < input.asOf,
);
if (overdueInvoices.length === 0) return undefined;
const amountCents = invoices.reduce(
(total, invoice) => total + invoice.amountCents,
0,
);
const oldestDueDate = overdueInvoices.reduce(
(oldest, invoice) =>
invoice.dueDate < oldest ? invoice.dueDate : oldest,
overdueInvoices[0].dueDate,
);
const daysOverdue = daysBetween(oldestDueDate, input.asOf);
const priority =
daysOverdue >= 14 || amountCents >= 200_000 ? "high" : "medium";
const recommendedAction =
priority === "high" ? "human_review" : "prepare_reminder";
return {
customerId,
customerName: invoices[0].customerName,
amountCents,
oldestDueDate,
daysOverdue,
priority,
recommendedAction,
reason: `${daysOverdue} days overdue with ${formatBrl(amountCents)} open`,
} satisfies PriorityAccount;
})
.filter((account): account is PriorityAccount => account !== undefined)
.sort(
(left, right) =>
right.daysOverdue - left.daysOverdue ||
right.amountCents - left.amountCents,
);
return {
totalOpenCents: openInvoices.reduce(
(total, invoice) => total + invoice.amountCents,
0,
),
accounts,
};
}Esse desenho faz duas coisas certas:
- uma regra operacional sensível fica no código, não em uma instrução improvisada no prompt;
- o resultado recomendado não executa cobrança, mensagem, pagamento ou mudança de cadastro.
É uma diferença importante. Um agente pode ajudar a ordenar trabalho. Ele não ganha automaticamente permissão para executar o trabalho que ordenou.
O mesmo princípio aparece em ferramentas como contrato: uma tool confiável precisa dizer o que aceita, o que devolve e onde termina sua responsabilidade. Para uma ação externa, a fronteira deve ser ainda mais rígida, com autorização explícita, idempotência e trilha de auditoria.
Quatro perguntas antes de expor uma tool
Antes de colocar uma tool MCP em uma URL, responda estas perguntas no código e nos testes:
1. O schema rejeita o que não faz sentido?
Formato não basta. Uma string no padrão AAAA-MM-DD pode ainda representar uma data impossível ou incompatível com a regra de negócio. Valide tipo, tamanho, faixas numéricas, datas semânticas e limites de coleção.
2. Quem pode usar esta operação?
“Não escreve” não significa “qualquer pessoa pode ler”. Dados de uma consulta, de um relatório ou de uma análise podem continuar sendo sensíveis. Defina autenticação e autorização para a superfície real da tool, não apenas para a interface web que a cerca.
3. Qual é o custo de repetir a chamada?
Meça CPU, banco, APIs externas e volume de resposta. Então aplique um limite durável no edge ou na infraestrutura que recebe o tráfego. Uma variável em memória de uma função serverless não é uma defesa confiável quando o processo escala ou reinicia.
4. O resultado expõe só o necessário?
Saída estruturada não é sinônimo de saída segura. Remova campos que não ajudam na decisão, evite ecoar payloads inteiros e trate texto vindo de fontes externas como dado, não como instrução para o modelo.
O teste que evita o rótulo decorativo
tools/list mostrar readOnlyHint: true é só o começo. O teste de release precisa provar comportamento:
entrada válida → resposta dentro do outputSchema
payload maior que o limite → rejeição
valor ou data inválidos → rejeição sem executar a lógica
identidade sem permissão → bloqueio
origem acima do limite → bloqueio
saída com campo proibido → ausência comprovadaSó depois disso faz sentido dizer que a operação é limitada. Antes, ela é apenas uma função com uma anotação otimista.
Fontes
- MCP Specification: Tools: anotações de tool,
outputSchemae requisitos de segurança para servidores. - Endpoint MCP publicado (
app/api/mcp/route.ts, trecho acima): schema de entrada e saída, anotações e ausência de ação externa na tool exposta. - Implementação de priorização (
lib/collections/portfolio.ts, trecho acima): filtragem de faturas e retorno de uma recomendação sem efeito externo. - PR #49: publicou o endpoint MCP via Streamable HTTP, com as verificações declaradas no release.
Próximo passo
Se seu agente já consulta ou prepara ações sobre dados operacionais, mas o contrato ainda mistura análise, permissão e efeito externo, traga o workflow para um diagnóstico de 30 minutos.
Posts relacionados
70 founders ao mesmo tempo: o que segura um agente aberto ao público
A pergunta 6 da série: limites. Leitura, preparação e escrita são três níveis de ação com três regras diferentes — e a trava real nunca mora no prompt.
Ferramentas como contrato: desenhando tools que o agente não usa errado
Um agente não chama sua API porque leu o código — ele lê a descrição e decide. Por isso cada ferramenta é um contrato: o que faz, entradas, saídas, erros, se é reversível e se precisa de aprovação.
O retry não é o problema. Repetir o efeito é.
Um timeout não diz se a escrita externa aconteceu. Antes de automatizar uma ação com IA, defina a chave que representa a intenção, o registro do efeito e o caminho para conflito.


