Skip to main content
Glama
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)