Obsidian MCP Server
Servidor MCP auto-hospedado para Obsidian: busca semântica e de texto completo, grafo de wikilinks, CRUD de notas, OAuth e um guia de vault auto-descritivo.
Documentação
Servidor MCP do Obsidian
Um servidor Model Context Protocol auto-hospedado que transforma seu cofre do Obsidian em memória compartilhada entre você e seus agentes de IA. Indexado, pesquisável e autodescritivo — os agentes leem o que você lê, vinculam o que você vincula e assimilam sua estrutura de pastas, esquema de frontmatter e convenções de tags na primeira chamada, em vez de receber instruções do zero a cada sessão.
Stack: Python 3.12, FastAPI, PostgreSQL com pgvector. Embeddings plugáveis (Ollama bge-m3 ou OpenAI text-embedding-3-{small,large}).

Conteúdo
- Por que isto existe
- Uma sessão no teclado
- O que está incluso
- vs. outros servidores MCP do Obsidian
- Para quem é isto
- Painel de controle
- Início rápido
- Expectativas de custo
- O cofre autodescritivo
- Modo multiusuário
- Configuração
- Arquitetura
- Estrutura do projeto
- Desenvolvimento
- Notas de segurança
Por que isto existe
Há três coisas acontecendo aqui, e elas são mais interessantes juntas do que separadas.
1. Uma camada de memória compartilhada entre você e seus agentes
Penso no meu cofre do Obsidian como meu exocórtex. O "eu ampliado" que inclui notas, calendários, scripts, busca e assistentes de IA é substancialmente mais capaz do que o "eu reduzido" do cérebro biológico sozinho. É também onde faço a maior parte do meu pensamento, porque escrever algo é em si uma forma de pensar.
O problema é que, até recentemente, o cofre era passivo. Eu tinha que ir buscar as coisas. Agentes que queriam me ajudar precisavam ser instruídos do zero a cada sessão, e não tinham como ver o que eu já havia escrito sobre um tópico.
Este servidor resolve isso. Agora o mesmo cofre alimenta minha escrita diária e qualquer agente que eu conecte a ele. O agente lê o que eu leio, vincula o que eu vinculo, segue os mesmos wikilinks, vê o mesmo frontmatter. Quando escrevo uma nota de projeto no domingo, meu agente de briefing de segunda-feira já sabe disso. Quando o agente deixa notas de uma sessão de pesquisa, elas aparecem na minha busca normal do Obsidian.
Uma versão concreta disso: passo uma sessão no Claude Code em um projeto, finalizo, envio os commits e depois apenas digo "atualize o Obsidian". O agente lê o guia do cofre, descobre onde as notas de projeto vivem na minha estrutura, escolhe o formato e o frontmatter corretos e deixa um registro de sessão que posso depois transformar em um relatório de status. Sem passar caminhos, sem dizer o que escrever — as convenções já estão no cofre, e ele as segue.
Essa é a ideia do exocórtex tornada concreta: um único lugar que guarda contexto, com o humano e os agentes lendo e escrevendo nele nos mesmos termos.
2. Memória de agente que você pode realmente ler
A outra metade é o inverso. Se você deixar um agente rodar por um tempo, ele precisa de memória. A maioria das configurações resolve isso com um armazenamento vetorial opaco, um blob SQLite ou um serviço de "memória" gerenciado no qual você não pode ver. Isso funciona até você querer saber o que o agente acha que sabe sobre você, ou precisar corrigir algo, ou quiser entender por que ele acabou de fazer uma sugestão estranha.
Este servidor oferece um acordo diferente. A memória do agente vive como arquivos markdown no seu cofre. Estrutura de pastas, nomes de arquivos, frontmatter, tudo visível. Você pode abrir o arquivo no Obsidian e lê-lo. Pode editá-lo. Pode excluí-lo. Pode usar grep nele. A "memória" do agente é um artefato auditável por humanos que fica no mesmo lugar que suas próprias notas, com as mesmas ferramentas disponíveis.
O laboratório doméstico é o caso de uso que me convenceu disso. Meu cofre tem notas sobre o rack, a rede e cada integração do Home Assistant. Posso dizer "configure um modo de luz noturna no banheiro principal, 1% depois das 23h" e um agente sysadmin encontra a configuração certa, faz a alteração e atualiza o documento na mesma passada. Seis meses depois, quando eu esqueci como funciona, a resposta está no cofre, não enterrada em algum histórico de chat que não consigo pesquisar.
A busca semântica e o grafo de wikilinks ainda funcionam sobre esse material, então a recuperação é rápida e conceitual. Mas o substrato são arquivos que você possui, não uma caixa preta.
3. O cofre segue você
O que ainda me surpreende é que isso é voltado para a internet. Mesmo cofre, mesmas notas, acessível de qualquer lugar onde eu esteja falando com um agente — Claude em uma aba do navegador, Claude no meu celular no trem, um fluxo n8n disparando em um agendamento, uma sessão do Claude Code em qualquer laptop à minha frente. Tudo atrás de OAuth ou uma chave bearer, tudo atingindo a mesma fonte de verdade.
Então, quando ouço um podcast interessante no trajeto e quero pensar em voz alta sobre ele com o Claude no meu celular, o agente não está começando do zero. Ele pode puxar o que já escrevi sobre tópicos adjacentes, trazer à tona uma nota que deixei pela metade há seis meses e, no final da conversa, sugerir atualizações e escrevê-las. O cofre não precisa estar perto de mim para ser a coisa em que estou trabalhando.
Uma sessão no teclado
Para tornar o abstrato concreto, um pequeno transcript de uma sessão real. Chamadas de ferramenta inline; o usuário digita em inglês simples e o agente escolhe a ferramenta certa.
you > find anything I've written about why retrieval-augmented
chat is hard
claude
→ semantic_search(query="why retrieval-augmented chat is hard")
• 2025-12-03 - RAG failure modes.md
• 2025-10-18 - Chunking tradeoffs.md
• 2024-06-22 - Vector recall vs precision.md
the closest hit is your December note on RAG failure modes. let
me see what it's connected to.
→ get_neighborhood(path="2025-12-03 - RAG failure modes.md",
depth=1)
8 connected notes. notable ones:
• "Hybrid search" — a re-ranking design you sketched but
didn't ship
• "Out-of-distribution queries" — a half-finished draft from
October
• "Embedding model choice" — links to bge-m3, your current
model
you have three connected pieces here: a failure-modes catalog,
an unshipped hybrid-search sketch, and an abandoned O-O-D draft.
want me to summarize all three together, or pull up the draft
so you can decide whether to finish it?
you > pull up the draft, and tag it #revisit-2026 in frontmatter
while you're there
claude
→ read_note(path="2025-10-18 - OOD queries (draft).md")
→ set_frontmatter(
path="2025-10-18 - OOD queries (draft).md",
updates={"tags": ["draft", "rag", "revisit-2026"]}
)
here's the draft, and the tag is on. you stopped right before
the section on confidence thresholds; the open question you
left yourself was…
Duas coisas para notar. Primeiro, o agente não precisou ser informado em qual pasta procurar ou quais ferramentas usar — ele as escolheu. Segundo, a escrita no final é estruturada (set_frontmatter mutando YAML, não um regex sobre o corpo do arquivo), então a nota faz ida e volta limpa. O cofre autodescritivo e o grafo de wikilinks estão fazendo o trabalho que torna isso natural.
O que está incluso
O servidor expõe 20 ferramentas MCP em seis áreas de preocupação.
Busca e descoberta
keyword_search(query, folder?, tags?, frontmatter?, limit=20), texto completo via PostgreSQLtsvector; a(s) configuração(ões) de busca de texto são configuráveis viaFTS_CONFIGS(veja Idioma(s) de busca de texto completo)semantic_search(query, folder?, tags?, frontmatter?, limit=15), similaridade vetorial via pgvector, um trecho de pré-visualização por notalist_notes(folder?, limit=50), ordenado por tempo de modificaçãoget_recent(folder?, limit=20), alterados recentementeget_tags(limit=50), tag e contagemget_vault_guide(), o primer do Obsidian mais oCLAUDE.mddeste cofre, servido ao vivo
Leitura e escrita
read_note(path, section?, offset=0, limit?), limitado porMAX_READ_RESPONSE_CHARS(padrão 40.000) — veja Limites de tamanho de resposta.section=<heading>retorna uma seção em vez da nota inteira;offsetcontinua uma leitura truncada.create_note(path, content), escrita atômica, recusa sobrescritaedit_note(path, …)com quatro modos mutuamente exclusivos: substituição completa (padrão),append=True,find=…(comreplace_allopcional) ousection=<heading>(cabeçalhos ATX, suportaParent/Childestilo caminho e#Ndesambiguação ordinal).dry_run=Trueretorna um diff unificado sem escrever. Clientes legados podem usaroperation="append";operation="replace"seleciona explicitamente substituição completa.move_note(from_path, to_path, rewrite_links=False), realoca e opcionalmente reescreve referências[[Old]],[[Old|alias]],[[Old#anchor]],![[Old]]e[[folder/Old]]recebidas nas notas de origemdelete_note(path, permanent=False), exclusão suave para.trash/<YYYYMMDD-HHMMSS>-<basename>por padrão.permanent=Truefaz umos.unlinkdefinitivo.set_frontmatter(path, updates, remove?), mutação YAML estruturada. O corpo fica byte-idêntico quando apenas o frontmatter muda.
Acesso a arquivos (não-markdown)
Leitura/escrita/navegação bruta de arquivos arbitrários do cofre (PDFs, imagens, ativos de habilidades, arquivos de dados) — pares distintos das ferramentas de nota, que permanecem apenas markdown. Transporte de bytes puro: sem extração de PDF/texto no servidor, sem incorporação ou indexação de arquivos não-markdown.
read_file(path, encoding="auto", offset=0, limit?), retorna arquivos semelhantes a texto como texto, imagens como um bloco de imagem inline que renderiza no cliente e outros binários como uma string base64.text/base64forçam a forma. Recusa arquivos acima deMAX_FILE_READ_BYTES(padrão 10 MB); resultados de texto são adicionalmente limitados porMAX_READ_RESPONSE_CHARSe continuam viaoffset.write_file(path, content, encoding="base64", overwrite=False), coloca um arquivo no cofre; base64 para binário,textpara UTF-8. Sem sobrescrita por padrão, cria automaticamente diretórios pais, escrita atômica. Limitado aMAX_FILE_WRITE_BYTES(padrão 25 MB).list_files(folder=".", pattern="*", recursive=False, limit=200), navegação estilolsde arquivos e subdiretórios com tamanho e mtime, filtrável por glob e com limite de resultados.delete_file(path, permanent=False), exclui suavemente um arquivo não-markdown para.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex>com um único rename atômico. Recusa markdown (isso édelete_note), diretórios e symlinks.
Todos os quatro reutilizam a proteção contra travessia de caminho e excluem diretórios de ponto (.obsidian, .git, .trash, …), correspondendo à regra de visibilidade do indexador.
Transferência de arquivos
Nenhum cliente MCP pode entregar a uma ferramenta os bytes de um arquivo que o usuário está vendo, então write_file só é utilizável quando o agente já tem o conteúdo. Essas ferramentas fecham essa lacuna com links de capacidade de curta duração, resgatados sobre as rotas públicas /transfer/*.
request_upload(path, overwrite=False, expires_in?), gera um link de uso único vinculado a exatamente um caminho de destino. O humano abre, escolhe um arquivo, e ele chega empath— nada mais pode ser escrito com ele.check_upload(upload_id), relatapending/uploading/completed(com tamanho, sha256 e MIME) /expired, com escopo para a identidade que o gerou.request_download(path, expires_in?), gera um link que o humano pode usar para salvar um arquivo do cofre. Utilizável mais de uma vez até expirar, e vinculado aos bytes exatos do arquivo no momento da geração.import_from_url(url, path, overwrite=False), busca um ativo https público diretamente no cofre sob uma política explícita de negação de saída (sem endereços privados, loopback, link-local, metadata ou tunelados, em qualquer grafia, re-verificados a cada redirecionamento).
O token viaja no fragmento da URL, que os navegadores nunca enviam, então nenhum alvo de requisição gerado pelo servidor ou log de acesso o contém. Uploads são reivindicados antes que um byte do corpo seja lido, publicados atomicamente com semântica de não-sobrescrita e vinculados no momento da geração ao estado do arquivo contra o qual foram gerados — um link não pode desfazer silenciosamente uma edição feita enquanto esperava. MCP_HOSTNAME ou BASE_URL devem ser definidos; sem uma origem pública, as ferramentas de geração recusam em vez de emitir um link localhost.
Grafo de wikilinks
get_backlinks(path, limit=50), notas que vinculam PARApathget_links(path), links de saída, tanto resolvidos quanto pendentesget_neighborhood(path, depth=1, limit=50), BFS não direcionado sobre o grafo de links resolvidos, limitado a profundidade ≤ 5 e limite ≤ 200find_related(path, limit=10), vizinhos semânticos via embeddings de trechos calculados pela média e distância de cosseno pgvector, deduplicados por notafind_orphans(folder?, limit=50), notas com zero links resolvidos de entrada ou saída
Autenticação e operações
- Chaves de API com o prefixo
omcp_, armazenadas como hashes SHA-256, com escopos de permissãoreadereadwrite. Ferramentas de escrita recusam em chaves somente leitura. - Fluxo OAuth 2.0 PKCE (S256) para clientes públicos e confidenciais, incluindo ChatGPT, Claude Desktop e claude.ai. O registro dinâmico padroniza ambos os níveis de permissão do cofre; o usuário escolhe a concessão real na tela de consentimento.
- Painel de controle (Jinja2, htmx, Tailwind) para chaves, logs de uso, status do indexador, informações do provedor de embeddings e um reset de zona de perigo.
- Cada chamada de ferramenta é registrada em
usage_logscom nome, parâmetros (truncados a 200 caracteres), duração e tamanho da resposta.
Todas as ferramentas de escrita passam por src/services/vault.py::write_file, que escreve em um arquivo tmp no mesmo diretório e o os.replace() no destino. Uma falha no meio da escrita não pode truncar uma nota.
vs. outros servidores MCP do Obsidian
Existem vários servidores MCP para Obsidian, e a maioria deles resolve um problema diferente deste. Os leves são cola sobre o plugin Local REST API do Obsidian ou o sistema de arquivos: eles permitem que um agente acesse os arquivos, mas não constroem infraestrutura própria. São ótimos se "eu só quero que o Claude leia minhas notas" é o objetivo e você mantém o Obsidian rodando localmente.
Este servidor está no outro extremo do espectro: um backend real com índice persistente, recuperação semântica, grafo de wikilinks, OAuth e uma UI administrativa. O custo é Postgres e Docker. O benefício é tudo o que você pode construir em cima disso.
| Este servidor | MarkusPfundstein/mcp-obsidian | StevenStavrakis/obsidian-mcp | jacksteamdev/obsidian-mcp-tools | |
|---|---|---|---|---|
| Índice persistente (Postgres) | ✅ | — | — | — |
| Busca semântica (vetores) | ✅ | — | — | — |
| Consultas ao grafo de wikilinks | ✅ | — | — | parcial |
| Funciona sem o Obsidian aberto | ✅ | — | ✅ | — |
| Fluxo de cliente OAuth 2.0 | ✅ | — | — | — |
| Multiusuário / vaults por usuário | ✅ | — | — | — |
| UI administrativa + logs de uso | ✅ | — | — | — |
| Gravações atômicas + diffs de simulação | ✅ | — | — | — |
| Custo de configuração | Postgres + Docker | Obsidian + plugin REST | Somente Python | Plugin Obsidian |
A comparação reflete os recursos documentados de cada projeto no momento da escrita; verifique os detalhes antes de apostar neles.
Para quem é este servidor
- Pessoas de homelab que já rodam Postgres e Docker, ou estão dispostas a configurá-los. O custo de configuração é o preço de entrada para as camadas semântica e de grafo.
- Pessoas que mantêm um vault com regras próprias — lógica de organização de tarefas, esquemas de frontmatter, taxonomia de tags — e querem que os agentes sigam essas convenções na primeira chamada, em vez de receber instruções a cada sessão.
- Quem roda mais de um cliente MCP (Claude Desktop, Claude Code, Claude no navegador, n8n) contra as mesmas notas e está cansado de reexplicar o vault para cada um.
- Pessoas que querem que a memória do agente viva como arquivos markdown simples que possam ler, editar, buscar com grep e versionar, não em um armazenamento vetorial opaco ou um serviço de memória gerenciado.
Para quem não é
- "Eu só quero que o Claude leia minhas notas" com a configuração mais leve possível. Use um dos projetos de cola de sistema de arquivos acima; você não precisa disso.
- Quem não quer rodar um banco de dados. Não há fallback para SQLite; o pgvector está fazendo trabalho real, e um Postgres gerenciado com suporte a pgvector faz parte da stack.
- Pessoas que querem um produto hospedado pronto para uso. Este é um servidor self-hosted que você mesmo executa.
Painel de controle
O servidor inclui uma UI administrativa embutida para as partes da operação que são mais fáceis de visualizar do que consultar: gerar chaves, acompanhar o indexador, observar o tráfego de chamadas de ferramentas e redefinir embeddings ao trocar de provedor.
Uso
Log de auditoria por chamada de ferramenta com histograma de requisições de 14 dias. Cada chamada MCP é registrada com a chave chamadora, nome da ferramenta, duração e tamanho da resposta — útil para perceber um agente com comportamento inadequado queimando tokens com algo que não deveria.

Chaves de API e clientes OAuth
Chaves Bearer com escopos read / readwrite para clientes de API, e um fluxo OAuth 2.0 PKCE separado para clientes como ChatGPT, Claude Desktop e claude.ai que esperam uma dança de código de autorização adequada. O servidor OAuth suporta autenticação de endpoint de token pública (none) e confidencial (client_secret_post), além de refresh tokens.

Navegador de vault
Uma árvore de arquivos somente leitura do vault montado, principalmente para verificar se o contêiner enxerga o que você acha que enxerga.

Configurações
Status do indexador, provedor e modelo de embedding atuais, caminho do vault e a zona de perigo de redefinição que remove e recria a coluna de embeddings na dimensão configurada. Use isso ao trocar de provedor.

Início rápido
Implantando em um VPS do zero? Veja
DEPLOYMENT.mdpara o passo a passo completo: configuração do Postgres, Caddy e TLS, sincronização do vault via Nextcloud e as pegadinhas que afetam implantações de primeira viagem.
A configuração Caddy incluída falha de forma segura em /admin, /api e
/authorize; substitua o hash de autenticação básica de exemplo antes de iniciá-lo.
Pré-requisitos
- Docker e Docker Compose
- Uma instância PostgreSQL 16 acessível a partir do contêiner, com a
extensão
pgvectorinstalada - Uma instância Ollama rodando
bge-m3, ou uma chave de API OpenAI. Qualquer coisa que fale o protocolo de embeddings da OpenAI funciona (Azure OpenAI, OpenRouter, Together, etc.).
1. Clone, configure, aponte para seu vault
git clone https://github.com/maxkuminov/obsidian-mcp.git
cd obsidian-mcp
cp .env.example .env
$EDITOR .env
Em docker-compose.yml, aponte o volume /obsidian para seu vault:
volumes:
- /path/to/your/vault:/obsidian
2. Escolha um backend de embeddings
Opção A, OpenAI (zero infraestrutura local):
EMBEDDING_PROVIDER=openai
OPENAI_API_KEY=sk-...
EMBEDDING_DIMENSIONS=1024
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
O servidor valida OPENAI_API_KEY na inicialização e se recusa a iniciar
se estiver ausente.
Opção B, Ollama (self-hosted, GPU recomendada):
EMBEDDING_PROVIDER=ollama
OLLAMA_URL=http://your-ollama-host:11434
EMBEDDING_MODEL=bge-m3
EMBEDDING_DIMENSIONS=1024
Este é o padrão. Omitir EMBEDDING_PROVIDER faz o fallback para
Ollama.
3. Implante
make init # data dirs and .env from template (skip if you've already edited)
make db-init # create database, user, and pgvector extension
make deploy # build, push to local registry, run migrations, recreate container
A primeira implantação preenche o índice, o grafo de wikilinks e os
embeddings. Para um vault de 2 a 3 mil notas no Ollama com GPU, isso leva
alguns minutos. Em text-embedding-3-small são segundos.
4. Conecte um cliente
Gere uma chave de API no painel de controle e aponte seu cliente MCP para:
URL: https://obsidian-mcp.<your-domain>/mcp
Auth: Bearer omcp_...
Para Claude Desktop, adicione em claude_desktop_config.json:
{
"mcpServers": {
"obsidian": {
"url": "https://obsidian-mcp.<your-domain>/mcp",
"headers": { "Authorization": "Bearer omcp_..." }
}
}
}
Para Claude Code:
claude mcp add obsidian --transport http \
--url "https://obsidian-mcp.<your-domain>/mcp" \
--header "Authorization: Bearer omcp_..."
A primeira coisa que qualquer agente deve fazer em uma nova sessão é chamar
get_vault_guide(). É assim que ele aprende sua estrutura de pastas,
convenções de nomenclatura e esquema YAML antes de escrever qualquer coisa.
Expectativas de custo
Se você seguir o caminho da OpenAI (o caminho realista em um VPS apenas com CPU), o gasto do primeiro índice é pequeno e o estado estável é quase gratuito. Números aproximados assumindo uma nota média de cerca de 1.500 tokens (três chunks de 512 tokens), na taxa publicada da OpenAI no momento da escrita:
| Modelo | $/1M tokens | 1k notas | 10k notas | 100k notas |
|---|---|---|---|---|
text-embedding-3-small | $0,02 | ~$0,05 | ~$0,50 | ~$5,00 |
text-embedding-3-large | $0,13 | ~$0,30 | ~$3,00 | ~$30,00 |
Após o primeiro índice, apenas notas alteradas são re-embeddadas. O custo contínuo é proporcional às edições — centavos por mês para um vault típico.
Se você hospedar Ollama com GPU, o custo de embeddings é o que sua conta de energia cobrar. Ollama em CPU funciona, mas é lento demais para ser utilizável em um vault com mais de algumas centenas de notas.
O vault autodescritivo
Esta é a parte que a maioria dos projetos "MCP para Obsidian" ignora. Eles param em ler, escrever e listar. A pergunta interessante não é "o agente consegue acessar os arquivos", é "o agente conhece as regras?"
Se você tem um vault com regras próprias — lógica de organização de tarefas, convenções de pastas, frontmatter obrigatório, taxonomia de tags — um agente com acesso de escrita pode causar danos reais sem esse contexto. Tarefas caem na pasta errada. Nomes de arquivo com data nua colidem com templates. Tags erradas quebram consultas Dataview. A camada de dados funciona bem; é na camada de contexto que as falhas aparecem.
A correção é pequena. Mantenha um arquivo de instruções legível por máquina
(CLAUDE.md na raiz do vault) que descreve as regras do próprio sistema.
Exponha-o como uma ferramenta dedicada. Cada agente conectado o chama uma vez no
início de uma sessão e imediatamente sabe como o vault funciona. Atualize o
arquivo, e todos os agentes veem a mudança na próxima chamada. Sem configuração
no lado do cliente. Sem injeção no system prompt. O vault é a autoridade sobre
suas próprias regras.
get_vault_guide() faz exatamente isso. Retorna uma introdução genérica ao
Obsidian (sintaxe de wikilinks, sintaxe de embeds, convenções de tags, literais
comuns de plugins) mais o CLAUDE.md do vault ao vivo. A dica para chamá-lo
primeiro está embutida nas descrições das ferramentas de escrita, para que o
agente seja puxado para o comportamento correto mesmo sem prompting.
Modo multiusuário
O modo de usuário único é o padrão e funciona exatamente como descrito acima — um vault, um conjunto de chaves de API, sem conceito de usuário no aplicativo. O modo multiusuário é uma flag opcional que transforma o mesmo contêiner em uma pequena implantação multi-tenant: login com nome de usuário/senha no aplicativo, escopo de vault por usuário, um papel de administrador para solução de problemas e um papel de usuário comum que vê apenas suas próprias chaves/clientes OAuth/uso. Um contêiner, um Postgres, isolamento estrito entre usuários.
Ative-o em uma implantação existente sem perda de dados — seu vault e chaves atuais são transferidos para o administrador de bootstrap.
Ativação
- Defina
MULTI_USER_MODE=truee umSECRET_KEYforte em.env(openssl rand -hex 32é suficiente). O aplicativo se recusa a iniciar com o valor de exemplo quando a flag está ativada. make deploy(oudocker compose up -d --force-recreate).- Visite o painel. Como a tabela
usersestá vazia, você é direcionado para/admin/register— o formulário de bootstrap único. Ele ainda está atrás do middlewarechain-oauth@filedo Traefik, então apenas pessoas que o Traefik já confia podem reivindicar o papel de administrador. - Registre-se com um nome de usuário e senha escolhidos. O formulário de
bootstrap pré-preenche
vault_pathcom o queVAULT_PATHfoi definido, então suas notas existentes pertencem imediatamente a este novo administrador. Sem re-indexação, sem re-embedding, sem perda de dados — cada nota previamente indexada, chave de API, cliente OAuth e linha de log de uso é preenchida retroativamente para o usuário de bootstrap em uma única transação.
Convidando usuários
-
Edite
docker-compose.ymlpara adicionar um volume para o vault do novo usuário em/vaults/<username>. Caminhos de host com espaços devem ser citados como uma única string YAML:volumes: - "/storage/vaults/alice:/vaults/alice" - "/storage/shared/bob/Obsidian:/vaults/bob"make deploypara aplicar. -
No painel,
/admin/users/create— escolha um nome de usuário e defina uma senha inicial. -
/admin/users/{id}/edit— defina ovault_pathdo usuário para o caminho do contêiner que você acabou de montar (por exemplo,/vaults/bob). O formulário mostra um menu suspenso de diretórios/vaults/*não atribuídos que existem no disco. -
Compartilhe as credenciais fora de banda. O usuário faz login em
/admin/auth/login, obtém suas próprias visualizações de chaves/OAuth/uso e não pode ver as notas de outros usuários.
O que os administradores veem
Os administradores veem chaves de API, clientes OAuth e logs de uso de todos os
usuários; eles controlam a página de Configurações (provedor de embeddings,
gatilho do indexador, zona de perigo) e a página de Usuários. Os administradores
não navegam pelo conteúdo dos vaults de outros usuários pelo painel — isso é
intencional. Solucionar problemas no vault de outro usuário significa inspecioná-lo
via docker exec ou reatribuir temporariamente seu vault_path, não
espionar pela interface.
Reversão
Defina MULTI_USER_MODE=false e reinicie. As chaves de API existentes continuam
funcionando (os filtros por usuário são ignorados quando não há contexto de
usuário definido), a interface de login e os cookies de sessão desaparecem, e o
painel volta ao modo somente OAuth do Traefik. O esquema permanece no lugar,
então voltar ao modo multiusuário depois retoma de onde parou sem re-bootstrap
(a tabela users não está vazia, então /admin/register está fechado).
Restrições e limites conhecidos
- O indexador itera usuários ativos sequencialmente a cada ciclo. Tudo bem para dezenas de usuários; centenas exigiriam paralelização.
- A redefinição de senha é apenas por administrador — não há fluxo de autoatendimento por e-mail.
- Sem limite de taxa em
/admin/auth/login. O gate OAuth do Traefik na frente do painel é a principal defesa contra força bruta; se você expor/admin/auth/loginà internet aberta, coloque um middleware de limite de taxa na frente dele. - O validador
vault_pathnão resolve symlinks, então um administrador pode tecnicamente apontar um usuário para arquivos do host via um/vaults/<name>com symlink. Trate/vaults/como um limite de confiança administrativa.
Configuração
| Variável | Padrão | Propósito |
|---|---|---|
DATABASE_URL | — | postgresql+asyncpg://user:pass@host/db |
VAULT_PATH | /obsidian | Montagem do vault no contêiner |
SECRET_KEY | — | Chave de assinatura itsdangerous |
INDEX_INTERVAL_SECONDS | 300 | Cadência de reindexação periódica |
MAX_FILE_READ_BYTES | 10485760 | Limite de read_file (10 MB); limita o que o servidor lê do disco |
MAX_FILE_WRITE_BYTES | 26214400 | Limite de write_file (25 MB), comprimento de bytes decodificados |
MAX_READ_RESPONSE_CHARS | 40000 | Limite de read_note / read_file sobre o que é retornado ao chamador (≈10K tokens). Veja Limites de tamanho de resposta. |
FTS_CONFIGS | english | Configuração(ões) de pesquisa de texto para busca por palavras-chave. JSON ou CSV. Veja Idioma(s) de pesquisa de texto completo. |
TRANSFER_TOKEN_TTL_SECONDS | 600 | Vida útil padrão de um link de transferência. O expires_in por chamada é limitado a 60–3600. |
TRANSFER_MAX_UPLOAD_SECONDS | 600 | Por quanto tempo um upload reivindicado pode transmitir antes que o token seja gasto |
TRANSFER_MAX_CONCURRENT_UPLOADS | 4 | Fluxos de upload simultâneos |
IMPORT_ALLOW_HTTP | false | Permite que import_from_url busque http simples. Desativado por padrão. |
EMBEDDING_PROVIDER | ollama | ollama ou openai |
EMBEDDING_DIMENSIONS | 1024 | Largura da coluna pgvector |
OLLAMA_URL | — | Usado quando o provedor é Ollama |
EMBEDDING_MODEL | bge-m3 | Nome do modelo Ollama |
OPENAI_API_KEY | — | Obrigatório quando o provedor é OpenAI |
OPENAI_BASE_URL | https://api.openai.com/v1 | Substituição para Azure ou proxies |
OPENAI_EMBEDDING_MODEL | text-embedding-3-small | Modelo OpenAI |
CHUNK_SIZE | 512 | Aproximadamente tokens por chunk (heurística de 4 caracteres) |
CHUNK_OVERLAP | 0 | Sobreposição de tokens entre chunks |
MCP_SANDBOX_MODE | false | Apenas para avaliação de registro. Ignora DB, indexador, provedor de embeddings e autenticação /mcp para que a introspecção funcione sem dependências externas. Não habilite em produção. |
Veja .env.example para o conjunto completo com comentários. Para gastos de primeira indexação na OpenAI, veja Expectativas de custo acima.
Trocando de provedores
Modelos diferentes produzem vetores não comparáveis, então uma troca de provedor exige reindexação.
make down- Atualize
.env. AltereEMBEDDING_PROVIDER, defina credenciais, opcionalmente altereEMBEDDING_DIMENSIONS. make reset-embeddingsmake up. A próxima passada do indexador re-gera os embeddings do vault.
Você também pode usar Configurações → Zona de perigo → Redefinir embeddings no painel de controle, que executa o mesmo SQL enquanto o servidor está em execução (pausa o indexador, executa o SQL, retoma).
Se você alterar EMBEDDING_DIMENSIONS sem executar a redefinição, o servidor detecta a incompatibilidade na inicialização e sai com código não zero, apontando para o alvo de redefinição.
Idioma(s) de pesquisa de texto completo
keyword_search é executado sobre um PostgreSQL tsvector. A configuração de pesquisa de texto que ele usa — o stemmer e o dicionário de stop-words — é controlada por FTS_CONFIGS. O padrão é english, que reproduz exatamente o comportamento histórico, então implantações existentes não precisam de ação.
FTS_CONFIGS é uma lista, configurável como JSON (FTS_CONFIGS=["simple","norwegian"]) ou separada por vírgulas (FTS_CONFIGS=simple,norwegian). Cada nota é indexada sob cada configuração listada, e uma consulta corresponde se a análise de qualquer configuração listada for encontrada. É isso que faz um vault multilíngue funcionar:
FTS_CONFIGS | Comportamento |
|---|---|
english | Stemmer Snowball inglês (padrão; running ↔ run). |
simple | Independente de idioma. Sem stemming ou stop-words — corresponde a formas exatas de palavras. Um padrão fundamentado para vaults multilíngues: a busca por palavras-chave é o braço de correspondência exata, enquanto semantic_search (bge-m3 é multilíngue) lida com a recuperação morfológica. |
english,norwegian | Ambos os stemmers aplicados — morfologia do lado da palavra-chave para dois idiomas ao mesmo tempo. |
simple,norwegian | Lexemas verbatim mais radicais noruegueses. |
A configuração é global — aplicada a todos os vaults (consistente com EMBEDDING_MODEL, CHUNK_SIZE, etc., que também são globais). Para uma instância multiusuário multilíngue, defina um superconjunto (por exemplo, ["english","norwegian"], ou ["simple"]). A configuração de FTS por usuário é uma extensão futura limpa, mas não está implementada.
Um nome de configuração com erro de digitação ou não instalado falha rapidamente na inicialização com uma mensagem listando as configurações disponíveis na sua instância Postgres, em vez de produzir buscas silenciosas com zero resultados.
Alterar FTS_CONFIGS exige uma reconstrução. Os tsvectors armazenados são calculados no momento da indexação, então eles ficam desatualizados quando a lista de configurações muda. Após editar .env e reimplantar, execute:
make rebuild-tsvectors
Isso relê cada nota e recalcula seu content_tsvector sob a(s) nova(s) configuração(ões). Ele reconstrói apenas o índice de palavras-chave — não toca em embeddings/vetores e não faz chamadas de API, então termina em segundos para alguns milhares de notas. (Não confunda com o fluxo caro de make reset-embeddings.)
Ressalva de tokenização: o parser de tsvector ainda divide em pontuação e hífens independentemente da configuração, então
bge-m3tokeniza embge+m3.simplepreserva formas de palavras, não strings com pontuação; correspondência exata de string com pontuação exigiria um índice trigrama e está fora do escopo.
Limites de tamanho de resposta
Um resultado de ferramenta é entrada do modelo. O que read_note retorna é alimentado diretamente na próxima solicitação do chamador, então uma leitura sem limite é um prompt sem limite — e o chamador geralmente descobre apenas quando seu provedor de inferência rejeita a solicitação.
MAX_READ_RESPONSE_CHARS (padrão 40.000, aproximadamente 10K tokens) limita o que read_note e os resultados de texto de read_file retornam. É um limite diferente de MAX_FILE_READ_BYTES, que limita o que o servidor lê do disco. Uma nota de 3 MB está confortavelmente dentro do limite de leitura de 10 MB e ainda assim destruirá uma janela de contexto; ambos os limites são necessários e têm valores corretos diferentes.
Ele se aplica por componente, não uma vez para toda a resposta: a janela de conteúdo recebe o limite, e o esboço de cabeçalhos o recebe independentemente. Uma leitura truncada pode conter ambos, então planeje um pior caso de aproximadamente 2 × MAX_READ_RESPONSE_CHARS mais algumas centenas de caracteres de texto de aviso fixo — não apenas um limite.
Quando uma nota excede o limite, você recebe a primeira janela mais um aviso [TRUNCATED] com o offset exato para continuar e — para uma leitura de nota inteira — um esboço das seções da nota:
- `#1` `# Client Records` (2,855,343 chars) ⚠ over the cap, will page
- `#2` `## Balance Sheet.xlsx` (391,199 chars) ⚠ over the cap, will page
- `#3` `## Lease Agreement.pdf` (464 chars)
- `#4` `## Invoice 2025-044.pdf` (1,075 chars) ← duplicate title, use the ordinal
Paginar uma nota de vários megabytes 40K por vez é tecnicamente possível e praticamente inútil, então prefira o esboço: leia a seção que deseja com read_note(path, section="Lease Agreement.pdf"). As seções são endereçáveis de três maneiras — o ordinal #N mostrado no esboço, a forma de caminho Parent/Child e o texto exato do cabeçalho. O ordinal é a única forma que separa cabeçalhos duplicados irmãos, que compartilham todos os ancestrais e, portanto, não podem ser desambiguados por caminho; notas geradas por extração em massa tendem a estar cheias deles.
Um #N simples sempre seleciona por posição, então um ordinal que fornecemos em um esboço nunca pode ser ofuscado por um cabeçalho que por acaso tenha o título #2. Esse cabeçalho permanece acessível pela forma de caminho (Parent/#2) ou pelo seu próprio ordinal.
O esboço em si é limitado pelo teto: uma nota com milhares de cabeçalhos recebe uma listagem truncada que informa quantas seções foram omitidas e a faixa ordinal completa, em vez de um esboço maior do que a janela de conteúdo que o acompanha.
limit pode reduzir o limite para uma única chamada, mas nunca aumentá-lo. Se seus clientes realmente desejam leituras maiores, aumente MAX_READ_RESPONSE_CHARS — essa é uma decisão do operador, tomada uma vez, por alguém que conhece a implantação.
Atualização: esta é uma mudança de contrato visível. Antes disso, um
read_noteem uma nota grande retornava tudo; agora ele trunca. O aviso é autodescritivo, então um agente não precisa de conhecimento prévio para continuar, mas um script que assumia leituras de nota inteira deve passarsection=ou aumentar o limite.
Arquitetura
┌──────────────┐ ┌──────────────────────┐
│ MCP clients │ HTTP + Bearer key │ FastAPI app │
│ Claude Desk │ ────────────────────▶ │ ┌────────────────┐ │
│ Claude Code │ │ │ MCP server │ │
│ n8n agents │ │ │ (20 tools) │ │
│ OpenWebUI │ │ └─────┬──────────┘ │
└──────────────┘ │ ▼ │
│ ┌────────────────┐ │
│ │ Services: │ │
│ │ - vault │ │
│ │ - search │ │
│ │ - embeddings │ │
│ │ - links │ │
│ │ - indexer │ │
│ └─────┬──────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Postgres + │ │
│ │ pgvector │ │
│ └────────────────┘ │
└──────────┬───────────┘
▼
┌────────────────────┐
│ Embedding │
│ provider │
│ (Ollama / OpenAI) │
└────────────────────┘
Pipeline de indexação
.md files in vault
↓ skip dot-dirs
parse frontmatter, extract tags (YAML + inline #hashtags)
↓ SHA-256 hash
skip if unchanged
↓
UPSERT notes_metadata (path, title, tags[], frontmatter JSONB,
content_hash, tsvector, modified_at)
↓
extract wikilinks/embeds/markdown-links → resolve targets →
note_links (source_id, target_id or NULL for dangling)
↓
chunk content (512 tokens, no overlap) → embed via provider →
note_embeddings (note_id, chunk_index, chunk_text, embedding[N])
↓
set embedded_content_hash = content_hash
O indexador é executado na inicialização e a cada INDEX_INTERVAL_SECONDS (5 minutos por padrão). Os hashes são apenas de conteúdo, então o detector de mudanças ignora variações de mtime. Embeddings desatualizados são detectados pela incompatibilidade de embedded_content_hash != content_hash.
Esquema do banco de dados
| Tabela | Propósito |
|---|---|
notes_metadata | Caminho, título, tags, frontmatter, hash de conteúdo, hash incorporado, tsvector, hora de modificação |
note_embeddings | Uma linha por chunk. embedding é vector(EMBEDDING_DIMENSIONS). |
note_links | Grafo de wikilinks: IDs de origem/destino, target_path, tipo (link, embed, markdown) |
api_keys | Tokens bearer com hash, prefixo para exibição, permissão, expiração |
usage_logs | Auditoria por chamada de ferramenta |
oauth_clients, oauth_codes, oauth_tokens | Estado OAuth 2.0 PKCE |
Índices GIN em content_tsvector e tags[]. Índices B-tree nas chaves estrangeiras quentes. Índice HNSW do pgvector na coluna de embeddings (vector_cosine_ops, m=16, ef_construction=64); consultas definem hnsw.ef_search=80 e deduplicam por nota em Python após um overfetch de 5x.
Estrutura do projeto
src/
main.py FastAPI app, lifespan, MCP mount
config.py pydantic-settings
database.py async SQLAlchemy engine/session
models/db.py ORM models
mcp_server/ MCP server, tools, auth middleware
services/ vault ops, search, embeddings, links, indexer
api/ control-panel REST endpoints
control_panel/ Jinja2 templates and static assets
oauth/ OAuth 2.0 authorization-code flow
alembic/ database migrations
scripts/ one-off ops scripts (e.g. reset_embeddings.py)
tests/ pytest suite + smoke-test docs
openspec/ change proposals (spec-driven workflow)
Desenvolvimento
pip install -r requirements-dev.txt
pytest
A suíte de testes unitários cobre a abstração do provedor de embeddings, o comportamento de lote e repetição da OpenAI, a validação de configuração e a verificação de incompatibilidade de dimensão na inicialização. Testes que dependem de rede usam respx para simular httpx, então nenhum acesso real à rede é necessário.
Para executar o servidor fora do Docker:
DATABASE_URL=... SECRET_KEY=... VAULT_PATH=... uvicorn src.main:app --reload
Alvos do Make
make init First-time setup (data dirs, .env)
make build Build Docker image (no cache)
make deploy Build, scan, push, backup, migrate, recreate container
make db-init Create database, user, and pgvector extension
make db-migrate Run alembic migrations
make db-backup Dump database to backups dir
make logs Tail container logs
make reindex Trigger a reindex via the API
make reset-embeddings Drop and recreate embedding column at configured dim
make rebuild-tsvectors Recompute keyword index for FTS_CONFIGS (no embeddings, no API calls)
make status Show container and health status
Notas de segurança
- Chaves de API usam o prefixo
omcp_e são armazenadas como hashes SHA-256. A chave bruta é mostrada exatamente uma vez na criação. - O painel de controle deve ficar atrás de um gateway de autenticação externo. O
docker-compose.ymlincluído usa Traefik com uma cadeia OAuth. Não exponha/admindiretamente à internet. - A chave da OpenAI é renderizada na página de configurações como
key[:8] + "..." + key[-4:]e nunca aparece completa em fontes HTML ou JS. - A travessia de caminho é bloqueada na camada de serviço. Todos os caminhos de escrita são resolvidos por meio de
Path.resolve().relative_to(vault_root). - Consultas parametrizadas em todos os lugares. Sem interpolação de strings em SQL.
- Os cabeçalhos de resposta incluem HSTS,
X-Content-Type-Options: nosniffeX-Frame-Options: DENY.
Status
Autor único, em uso ativo como exocórtex pessoal do mantenedor (mais de 2.500 notas, múltiplos agentes conectados). Público para qualquer pessoa que queira fazer um fork. Issues e PRs são bem-vindos, mas espere revisão opinativa. Este é um sistema funcional, não uma plataforma genérica.
Licença
MIT. Veja LICENSE.