mcp-server-example — servidor MCP para uma base de notas em Markdown
Um servidor MCP (Model Context Protocol) de exemplo, funcional e testado, que dá a um assistente acesso a um second brain: um diretório local de notas em Markdown que ele pode criar, ler, atualizar, listar, buscar e medir.
O foco aqui não é a quantidade de recursos, e sim mostrar um servidor MCP honesto: schemas gerados a partir dos type hints, sanitização de verdade contra path traversal, e uma suíte de testes que chama as ferramentas de verdade em vez de simular a chamada.
O que é MCP
O Model Context Protocol é um protocolo aberto que padroniza como um assistente conversa com sistemas externos. Em vez de cada aplicação inventar seu próprio formato de plugin, o servidor MCP declara três coisas — tools (ações que o modelo pode executar), resources (dados que ele pode ler, endereçados por URI) e prompts (modelos de conversa que o usuário pode invocar) — e qualquer cliente compatível descobre e usa tudo isso sozinho. A comunicação é JSON-RPC, normalmente sobre stdio: o cliente sobe o servidor como um subprocesso e troca mensagens pela entrada e saída padrão.
O que tem aqui
| Arquivo | O que faz |
|---|---|
mcp_notas/server.py |
Define o servidor FastMCP: tools, resources, prompts e os modelos Pydantic de saída. |
mcp_notas/storage.py |
Todo o I/O em disco e a sanitização de identificadores. Único ponto que monta caminhos. |
mcp_notas/search.py |
Busca textual com ranking por campo (título > tags > corpo), insensível a acento. |
mcp_notas/__main__.py |
Ponto de entrada de python3 -m mcp_notas. |
tests/test_server.py |
45 testes que exercitam o servidor de verdade, incluindo uma sessão MCP completa. |
requirements.txt |
Dependências de runtime e de teste. |
pytest.ini |
Configuração do pytest-asyncio. |
Cada nota é um arquivo .md com um front matter mínimo:
---
title: Teste env
tags: []
created: 2026-08-25T00:20:24+00:00
updated: 2026-08-25T00:20:24+00:00
---
O que o servidor expõe
Tools
| Tool | Argumentos | Devolve |
|---|---|---|
criar_nota |
titulo (obrigatório), corpo, tags, slug |
A nota criada, com datas preenchidas. |
ler_nota |
slug |
A nota completa (corpo, tags, datas). |
atualizar_nota |
slug, corpo, titulo, tags, anexar |
A nota já atualizada. |
apagar_nota |
slug |
Confirmação em texto. |
listar_notas |
tag (opcional) |
Total e resumo de cada nota, sem o corpo. |
buscar_notas |
consulta, limite |
Resultados ordenados por relevância, com trecho. |
estatisticas_base |
— | Contagens, tags mais usadas, nota mais longa. |
Resources
| URI | Tipo | Conteúdo |
|---|---|---|
notas://index |
application/json |
Índice de toda a base: slug, título, tags e URI de cada nota. |
notas://{slug} |
text/markdown |
Markdown integral de uma nota, com front matter. |
Prompts
| Prompt | Argumentos | O que monta |
|---|---|---|
resumir_nota |
slug, tamanho (curto/longo) |
Um pedido de resumo com o conteúdo da nota já embutido. |
sugerir_conexoes |
slug, quantidade |
Quatro mensagens: instrução, nota de partida, catálogo das demais notas e a abertura do assistente. |
Instalação
git clone <url-do-repositorio> mcp-server-example
cd mcp-server-example
pip install -r requirements.txt
Requer Python 3.11+ e mcp >= 1.27.0.
Como rodar
O transporte padrão é stdio — é assim que um cliente MCP sobe o servidor:
cd mcp-server-example
python3 -m mcp_notas
O processo fica em silêncio esperando mensagens JSON-RPC na entrada padrão; isso é o comportamento correto, não um travamento.
O diretório da base é configurável pela variável de ambiente MCP_NOTAS_DIR
(padrão: ./notas, criado automaticamente):
MCP_NOTAS_DIR=~/meu-second-brain python3 -m mcp_notas
Configuração no cliente
Bloco pronto para colar na configuração de um cliente MCP:
{
"mcpServers": {
"notas": {
"command": "python3",
"args": ["-m", "mcp_notas"],
"cwd": "/caminho/absoluto/para/mcp-server-example",
"env": {
"MCP_NOTAS_DIR": "/caminho/absoluto/para/suas-notas"
}
}
}
}
⚠️ Este bloco não foi testado contra um cliente MCP real neste ambiente. O que foi verificado aqui é o equivalente programático: o servidor foi subido como subprocesso com
python3 -m mcp_notase umClientSessiondo próprio SDK completou o handshake por stdio, listou as tools e executou chamadas (ver "Status de verificação"). A tradução desse handshake para o formato de configuração de um cliente específico não foi exercitada.
Exemplo de uso
Saídas reais, capturadas rodando o servidor in-process (criar_servidor() + call_tool).
O campo diretorio foi trocado por um caminho genérico; o resto é literal.
>>> criar_nota
{
"slug": "protocolo-mcp",
"titulo": "Protocolo MCP",
"tags": [
"mcp",
"protocolo"
],
"corpo": "O Model Context Protocol padroniza como um assistente acessa ferramentas e dados externos.",
"criada_em": "2026-08-25T00:20:03+00:00",
"atualizada_em": "2026-08-25T00:20:03+00:00"
}
>>> listar_notas(tag='mcp')
{
"total": 1,
"filtro_tag": "mcp",
"notas": [
{
"slug": "protocolo-mcp",
"titulo": "Protocolo MCP",
"tags": [
"mcp",
"protocolo"
],
"atualizada_em": "2026-08-25T00:20:03+00:00",
"resumo": "O Model Context Protocol padroniza como um assistente acessa ferramentas e dados externos.",
"tamanho": 90
}
]
}
>>> buscar_notas(consulta='protocolo')
{
"consulta": "protocolo",
"total": 2,
"resultados": [
{
"slug": "protocolo-mcp",
"titulo": "Protocolo MCP",
"tags": [
"mcp",
"protocolo"
],
"pontuacao": 8.0,
"trecho": "O Model Context Protocol padroniza como um assistente acessa ferramentas e dados externos."
},
{
"slug": "memoria-de-longo-prazo",
"titulo": "Memória de longo prazo",
"tags": [
"produtividade"
],
"pontuacao": 1.0,
"trecho": "Anotações sobre second brain. Cita o protocolo de revisão semanal."
}
]
}
Repare no ranking: a palavra "protocolo" está no título e nas tags da primeira nota (pontuação 8.0) e apenas no corpo da segunda (pontuação 1.0).
>>> estatisticas_base()
{
"total_de_notas": 2,
"total_de_caracteres": 156,
"total_de_palavras": 23,
"media_de_caracteres": 78.0,
"total_de_tags": 3,
"tags_mais_usadas": {
"mcp": 1,
"produtividade": 1,
"protocolo": 1
},
"nota_mais_longa": "protocolo-mcp",
"ultima_atualizacao": "2026-08-25T00:20:03+00:00",
"diretorio": "/caminho/para/notas"
}
>>> read_resource('notas://index')
{
"diretorio": "/caminho/para/notas",
"total": 2,
"notas": [
{
"slug": "memoria-de-longo-prazo",
"titulo": "Memória de longo prazo",
"tags": [
"produtividade"
],
"uri": "notas://memoria-de-longo-prazo"
},
{
"slug": "protocolo-mcp",
"titulo": "Protocolo MCP",
"tags": [
"mcp",
"protocolo"
],
"uri": "notas://protocolo-mcp"
}
]
}
>>> get_prompt('resumir_nota', {'slug': 'protocolo-mcp'})
Resuma em no máximo 3 bullets.
Não invente informação que não esteja na nota.
# Protocolo MCP
Tags: mcp, protocolo
O Model Context Protocol padroniza como um assistente acessa ferramentas e dados externos.
E o handshake real por stdio, com o servidor rodando como subprocesso e um ClientSession
do SDK do outro lado (saída literal, sem os logs INFO do servidor):
serverInfo: mcp-notas 1.27.0
instructions[:60]: Servidor de uma base local de notas em Markdown. Use 'listar
tools: ['apagar_nota', 'atualizar_nota', 'buscar_notas', 'criar_nota', 'estatisticas_base', 'ler_nota', 'listar_notas']
criar_nota isError: False slug: handshake-stdio
estatisticas: 1 nota(s)
traversal isError: True
traversal msg: Error executing tool ler_nota: Identificador inválido '../../etc/passwd': separadores de caminho não são permitidos. Use
Segurança
O bug clássico de servidor MCP que mexe em arquivos é aceitar um identificador vindo do modelo e
concatená-lo direto no caminho: Path(base) / slug. Com slug = "../../etc/passwd", isso entrega
o disco inteiro para quem controlar o prompt.
Aqui a defesa está em mcp_notas/storage.py e tem duas camadas.
1. sanitizar_slug() — validação por lista de permissão. Um identificador só passa se casar
com ^[a-z0-9][a-z0-9._-]{0,79}$, depois de rejeitar explicitamente separadores de caminho
(/, \), byte nulo, letras de unidade do Windows (C:) e qualquer ocorrência de ... Exigir
que comece por letra ou dígito também derruba nomes ocultos como .ssh.
2. BaseDeNotas.caminho() — verificação do caminho resolvido. Depois de sanitizar, o caminho
é resolvido com Path.resolve() e o código confere que o pai dele é exatamente o diretório da
base. Essa checagem é redundante por construção — e é esse o ponto: se algum dia a primeira
camada tiver um furo, o vazamento ainda não acontece.
O ataque canônico, executado de verdade contra a tool:
>>> call_tool('ler_nota', {'slug': '../../etc/passwd'})
ToolError: Error executing tool ler_nota: Identificador inválido '../../etc/passwd': separadores de caminho não são permitidos. Use apenas o slug da nota, sem diretórios.
O resource notas://{slug} tem a mesma proteção, e por dois caminhos diferentes: a URI crua
notas://../../etc/passwd nem casa com o template (Unknown resource), enquanto a forma
percent-encoded notas://..%2F..%2Fetc%2Fpasswd casa, chega à sanitização e é barrada lá — é
esse segundo caso, o perigoso, que o teste cobre.
Um teste também prova no sistema de arquivos que o alvo do ataque não chega a ser criado: depois
de uma tentativa de criar_nota com slug="../vazamento", o diretório da base continua vazio e
o arquivo fora dele não existe.
Além disso: nenhuma chave de API, nenhum acesso de rede, e o servidor nunca lê ou escreve fora do diretório configurado.
Testes
$ python3 -m pytest tests/ -q
............................................. [100%]
45 passed in 1.48s
Só os testes de path traversal:
$ python3 -m pytest tests/ -q -k traversal
................. [100%]
17 passed, 28 deselected in 0.67s
A suíte cobre, em ordem:
- Sanitização — 13 entradas maliciosas parametrizadas (
../../etc/passwd,/etc/passwd,..\\..\\windows\\system32\\config\\sam,C:\Windows\win.ini,nota\x00.md, string vazia…), mais a prova em disco de que nada é criado fora da base. - Superfície MCP —
list_toolsdevolve exatamente as sete tools, e os schemas (required,type,default,outputSchema) são os gerados a partir dos type hints e docstrings. - Chamada real de cada tool — criação com persistência verificada em disco, duplicata,
leitura, leitura de inexistente, atualização, atualização com
anexar, listagem com e sem filtro de tag, busca com ranking e com limite, estatísticas e remoção. - Resources —
list_resources,list_resource_templates, leitura do índice JSON, leitura de uma nota individual e as duas formas de traversal. - Prompts —
list_prompts,get_promptdos dois prompts, conferindo que o conteúdo da nota é realmente embutido e que a nota de partida não aparece no catálogo das outras. - Sessão ponta a ponta —
create_connected_server_and_client_sessionsobe um cliente e um servidor MCP conectados em memória; o teste lista tools, cria nota, lista, lê resource, pega prompt e confirmaisError: Truena tentativa de traversal. - Armazenamento isolado — round-trip do front matter e arquivos que não são notas sendo ignorados na listagem.
Status de verificação
Tudo abaixo foi executado neste ambiente, com mcp 1.27.0, pytest 9.1.1 e
pytest-asyncio 1.4.0 sob Python 3.11.
✅ Verificado
python3 -m pytest tests/ -q→ 45 passed.- As sete tools chamadas de verdade via
FastMCP.call_tool, com os resultados conferidos. - Os dois resources lidos via
FastMCP.read_resource; os dois prompts viaFastMCP.get_prompt. - Sessão MCP completa cliente↔servidor em memória com
mcp.shared.memory.create_connected_server_and_client_session. - Handshake stdio real: servidor subido como subprocesso (
python3 -m mcp_notas) e umClientSessiondo SDK executandoinitialize,list_toolsecall_toolpor ele. - Path traversal rejeitado em
sanitizar_slug, na tool, no resource e no sistema de arquivos. MCP_NOTAS_DIRrespeitado: a nota criada apareceu no diretório apontado pela variável.- Todas as saídas mostradas neste README foram copiadas de execuções reais.
⚠️ Não testado
- O bloco
mcpServersnão foi testado contra um cliente MCP real (Claude Desktop, editores, etc.). Não há nenhum cliente instalado neste ambiente; o que substitui essa verificação é o handshake stdio programático descrito acima. - Os transportes
sseestreamable-httpexistem emFastMCP.run, mas este projeto só exercitastdio. - Sem testes de concorrência: escritas simultâneas na mesma nota não são coordenadas por lock.
- Sem testes em Windows ou macOS — só Linux.