mcp-notas
README.md
# 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:
```markdown
---
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
```bash
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:
```bash
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):
```bash
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:
```json
{
"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_notas` e um `ClientSession` do 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:
1. **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.
2. **Superfície MCP** — `list_tools` devolve exatamente as sete tools, e os schemas (`required`,
`type`, `default`, `outputSchema`) são os gerados a partir dos type hints e docstrings.
3. **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.
4. **Resources** — `list_resources`, `list_resource_templates`, leitura do índice JSON, leitura
de uma nota individual e as duas formas de traversal.
5. **Prompts** — `list_prompts`, `get_prompt` dos 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.
6. **Sessão ponta a ponta** — `create_connected_server_and_client_session` sobe um cliente e um
servidor MCP conectados em memória; o teste lista tools, cria nota, lista, lê resource, pega
prompt e confirma `isError: True` na tentativa de traversal.
7. **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 via `FastMCP.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 um
`ClientSession` do SDK executando `initialize`, `list_tools` e `call_tool` por ele.
- Path traversal rejeitado em `sanitizar_slug`, na tool, no resource e no sistema de arquivos.
- `MCP_NOTAS_DIR` respeitado: 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 `mcpServers` nã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 `sse` e `streamable-http` existem em `FastMCP.run`, mas este projeto só exercita
`stdio`.
- 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.
---
## Licença
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues