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

Python License MCP PostgreSQL

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}).

Dashboard

Conteúdo

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 PostgreSQL tsvector; a(s) configuração(ões) de busca de texto são configuráveis via FTS_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 nota
  • list_notes(folder?, limit=50), ordenado por tempo de modificação
  • get_recent(folder?, limit=20), alterados recentemente
  • get_tags(limit=50), tag e contagem
  • get_vault_guide(), o primer do Obsidian mais o CLAUDE.md deste cofre, servido ao vivo

Leitura e escrita

  • read_note(path, section?, offset=0, limit?), limitado por MAX_READ_RESPONSE_CHARS (padrão 40.000) — veja Limites de tamanho de resposta. section=<heading> retorna uma seção em vez da nota inteira; offset continua uma leitura truncada.
  • create_note(path, content), escrita atômica, recusa sobrescrita
  • edit_note(path, …) com quatro modos mutuamente exclusivos: substituição completa (padrão), append=True, find=… (com replace_all opcional) ou section=<heading> (cabeçalhos ATX, suporta Parent/Child estilo caminho e #N desambiguação ordinal). dry_run=True retorna um diff unificado sem escrever. Clientes legados podem usar operation="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 origem
  • delete_note(path, permanent=False), exclusão suave para .trash/<YYYYMMDD-HHMMSS>-<basename> por padrão. permanent=True faz um os.unlink definitivo.
  • 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/base64 forçam a forma. Recusa arquivos acima de MAX_FILE_READ_BYTES (padrão 10 MB); resultados de texto são adicionalmente limitados por MAX_READ_RESPONSE_CHARS e continuam via offset.
  • write_file(path, content, encoding="base64", overwrite=False), coloca um arquivo no cofre; base64 para binário, text para UTF-8. Sem sobrescrita por padrão, cria automaticamente diretórios pais, escrita atômica. Limitado a MAX_FILE_WRITE_BYTES (padrão 25 MB).
  • list_files(folder=".", pattern="*", recursive=False, limit=200), navegação estilo ls de 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 em path — nada mais pode ser escrito com ele.
  • check_upload(upload_id), relata pending / 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 PARA path
  • get_links(path), links de saída, tanto resolvidos quanto pendentes
  • get_neighborhood(path, depth=1, limit=50), BFS não direcionado sobre o grafo de links resolvidos, limitado a profundidade ≤ 5 e limite ≤ 200
  • find_related(path, limit=10), vizinhos semânticos via embeddings de trechos calculados pela média e distância de cosseno pgvector, deduplicados por nota
  • find_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ão read e readwrite. 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_logs com 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 servidorMarkusPfundstein/mcp-obsidianStevenStavrakis/obsidian-mcpjacksteamdev/obsidian-mcp-tools
Índice persistente (Postgres)
Busca semântica (vetores)
Consultas ao grafo de wikilinksparcial
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çãoPostgres + DockerObsidian + plugin RESTSomente PythonPlugin 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.

Usage

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.

API keys OAuth clients

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.

Vault

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.

Settings

Início rápido

Implantando em um VPS do zero? Veja DEPLOYMENT.md para 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 pgvector instalada
  • 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 tokens1k notas10k notas100k 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

  1. Defina MULTI_USER_MODE=true e um SECRET_KEY forte em .env (openssl rand -hex 32 é suficiente). O aplicativo se recusa a iniciar com o valor de exemplo quando a flag está ativada.
  2. make deploy (ou docker compose up -d --force-recreate).
  3. Visite o painel. Como a tabela users está vazia, você é direcionado para /admin/register — o formulário de bootstrap único. Ele ainda está atrás do middleware chain-oauth@file do Traefik, então apenas pessoas que o Traefik já confia podem reivindicar o papel de administrador.
  4. Registre-se com um nome de usuário e senha escolhidos. O formulário de bootstrap pré-preenche vault_path com o que VAULT_PATH foi 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

  1. Edite docker-compose.yml para 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 deploy para aplicar.

  2. No painel, /admin/users/create — escolha um nome de usuário e defina uma senha inicial.

  3. /admin/users/{id}/edit — defina o vault_path do 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.

  4. 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_path nã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ávelPadrãoPropósito
DATABASE_URLpostgresql+asyncpg://user:pass@host/db
VAULT_PATH/obsidianMontagem do vault no contêiner
SECRET_KEYChave de assinatura itsdangerous
INDEX_INTERVAL_SECONDS300Cadência de reindexação periódica
MAX_FILE_READ_BYTES10485760Limite de read_file (10 MB); limita o que o servidor lê do disco
MAX_FILE_WRITE_BYTES26214400Limite de write_file (25 MB), comprimento de bytes decodificados
MAX_READ_RESPONSE_CHARS40000Limite de read_note / read_file sobre o que é retornado ao chamador (≈10K tokens). Veja Limites de tamanho de resposta.
FTS_CONFIGSenglishConfiguraçã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_SECONDS600Vida útil padrão de um link de transferência. O expires_in por chamada é limitado a 60–3600.
TRANSFER_MAX_UPLOAD_SECONDS600Por quanto tempo um upload reivindicado pode transmitir antes que o token seja gasto
TRANSFER_MAX_CONCURRENT_UPLOADS4Fluxos de upload simultâneos
IMPORT_ALLOW_HTTPfalsePermite que import_from_url busque http simples. Desativado por padrão.
EMBEDDING_PROVIDERollamaollama ou openai
EMBEDDING_DIMENSIONS1024Largura da coluna pgvector
OLLAMA_URLUsado quando o provedor é Ollama
EMBEDDING_MODELbge-m3Nome do modelo Ollama
OPENAI_API_KEYObrigatório quando o provedor é OpenAI
OPENAI_BASE_URLhttps://api.openai.com/v1Substituição para Azure ou proxies
OPENAI_EMBEDDING_MODELtext-embedding-3-smallModelo OpenAI
CHUNK_SIZE512Aproximadamente tokens por chunk (heurística de 4 caracteres)
CHUNK_OVERLAP0Sobreposição de tokens entre chunks
MCP_SANDBOX_MODEfalseApenas 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.

  1. make down
  2. Atualize .env. Altere EMBEDDING_PROVIDER, defina credenciais, opcionalmente altere EMBEDDING_DIMENSIONS.
  3. make reset-embeddings
  4. make 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_CONFIGSComportamento
englishStemmer Snowball inglês (padrão; runningrun).
simpleIndependente 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,norwegianAmbos os stemmers aplicados — morfologia do lado da palavra-chave para dois idiomas ao mesmo tempo.
simple,norwegianLexemas 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-chavenã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-m3 tokeniza em bge + m3. simple preserva 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_note em 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 passar section= 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

TabelaPropósito
notes_metadataCaminho, título, tags, frontmatter, hash de conteúdo, hash incorporado, tsvector, hora de modificação
note_embeddingsUma linha por chunk. embedding é vector(EMBEDDING_DIMENSIONS).
note_linksGrafo de wikilinks: IDs de origem/destino, target_path, tipo (link, embed, markdown)
api_keysTokens bearer com hash, prefixo para exibição, permissão, expiração
usage_logsAuditoria por chamada de ferramenta
oauth_clients, oauth_codes, oauth_tokensEstado 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.yml incluído usa Traefik com uma cadeia OAuth. Não exponha /admin diretamente à 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: nosniff e X-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.