Obsidian MCP Server
Servidor MCP autoalojado para Obsidian: búsqueda semántica y de texto completo, grafo de wikilinks, CRUD de notas, OAuth y una guía de bóveda autodescriptiva.
Documentación
Servidor MCP de Obsidian
Un servidor Model Context Protocol autohospedado que convierte tu bóveda de Obsidian en memoria compartida entre tú y tus agentes de IA. Indexado, buscable y autodescriptivo: los agentes leen lo que tú lees, enlazan lo que tú enlazas y captan tu estructura de carpetas, esquema de frontmatter y convenciones de etiquetas en la primera llamada, en lugar de recibir instrucciones desde cero en cada sesión.
Stack: Python 3.12, FastAPI, PostgreSQL con pgvector. Incrustaciones
enchufables (Ollama bge-m3, o OpenAI text-embedding-3-{small,large}).

Contenido
- Por qué existe esto
- Una sesión frente al teclado
- Qué incluye
- Comparación con otros servidores MCP de Obsidian
- Para quién es
- Panel de control
- Inicio rápido
- Expectativas de costos
- La bóveda autodescriptiva
- Modo multiusuario
- Configuración
- Arquitectura
- Estructura del proyecto
- Desarrollo
- Notas de seguridad
Por qué existe esto
Hay tres cosas en juego aquí, y son más interesantes juntas que por separado.
1. Una capa de memoria compartida entre tú y tus agentes
Pienso en mi bóveda de Obsidian como mi exocórtex. El "yo grande" que incluye notas, calendarios, scripts, búsqueda y asistentes de IA es sustancialmente más capaz que el "yo pequeño" del cerebro biológico solo. También es donde hago la mayor parte de mi pensamiento, porque escribir algo es en sí mismo una forma de pensar.
El problema es que hasta hace poco, la bóveda era pasiva. Tenía que ir a buscar las cosas. Los agentes que querían ayudarme tenían que recibir instrucciones desde cero en cada sesión, y no tenían forma de ver lo que ya había escrito sobre un tema.
Este servidor soluciona eso. Ahora la misma bóveda alimenta mi escritura diaria y cualquier agente que conecte a ella. El agente lee lo que yo leo, enlaza lo que yo enlazo, sigue los mismos wikilinks, ve el mismo frontmatter. Cuando escribo una nota de proyecto el domingo, mi agente de informe del lunes por la mañana ya lo sabe. Cuando el agente deja notas de una sesión de investigación, aparecen en mi búsqueda normal de Obsidian.
Una versión concreta de esto: paso una sesión en Claude Code en un proyecto, termino, hago push de los commits, y luego simplemente digo "actualiza Obsidian". El agente lee la guía de la bóveda, descubre dónde viven las notas de proyecto en mi estructura, elige el formato y frontmatter correctos, y deja un registro de sesión que luego puedo convertir en un informe de estado. Sin pasar rutas, sin decirle qué escribir: las convenciones ya están en la bóveda, y las sigue.
Esa es la idea del exocórtex hecha concreta: un solo lugar que contiene contexto, y tanto el humano como los agentes leen y escriben en él en los mismos términos.
2. Memoria de agente que realmente puedes leer
La otra mitad es la inversa. Si dejas que un agente funcione por un tiempo, necesita memoria. La mayoría de las configuraciones resuelven esto con un almacén de vectores opaco, un blob de SQLite o un servicio de "memoria" gestionado en el que no puedes ver. Eso funciona hasta que quieres saber qué cree el agente que sabe sobre ti, o necesitas corregir algo, o quieres entender por qué acaba de hacer una sugerencia extraña.
Este servidor te ofrece un trato diferente. La memoria del agente vive como archivos markdown en tu bóveda. Estructura de carpetas, nombres de archivo, frontmatter, todo visible. Puedes abrir el archivo en Obsidian y leerlo. Puedes editarlo. Puedes eliminarlo. Puedes hacer grep. La "memoria" del agente es un artefacto auditable por humanos que se encuentra en el mismo lugar que tus propias notas, con las mismas herramientas disponibles.
El laboratorio doméstico es el caso de uso que me convenció de esto. Mi bóveda tiene notas sobre el rack, la red y cada integración de Home Assistant. Puedo decir "configura un modo de luz nocturna en el baño principal, 1% después de las 23:00" y un agente administrador de sistemas encuentra la configuración correcta, hace el cambio y actualiza el documento en la misma pasada. Seis meses después, cuando he olvidado cómo funciona, la respuesta está en la bóveda, no enterrada en algún historial de chat que no puedo buscar.
La búsqueda semántica y el grafo de wikilinks siguen funcionando sobre ese material, así que la recuperación es rápida y conceptual. Pero el sustrato son archivos que posees, no una caja negra.
3. La bóveda te sigue
Lo que todavía me sorprende es que esto está orientado a internet. Misma bóveda, mismas notas, accesibles desde cualquier lugar donde esté hablando con un agente: Claude en una pestaña del navegador, Claude en mi teléfono en el tren, un flujo de n8n ejecutándose en un horario, una sesión de Claude Code en cualquier laptop que tenga delante. Todo detrás de OAuth o una clave bearer, todo apuntando a la misma fuente de verdad.
Así que cuando escucho un podcast interesante en un viaje y quiero pensar en voz alta sobre ello con Claude en mi teléfono, el agente no está empezando en frío. Puede recuperar lo que ya he escrito sobre temas adyacentes, sacar una nota que dejé a medias hace seis meses, y al final de la conversación sugerir actualizaciones y escribirlas. La bóveda no tiene que estar cerca de mí para ser el lugar donde estoy trabajando.
Una sesión frente al teclado
Para hacer lo abstracto concreto, un breve transcripto de una sesión real. Llamadas de herramientas en línea; el usuario escribe en inglés simple y el agente elige la herramienta correcta.
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…
Dos cosas para notar. Primero, el agente no necesitó que le dijeran en qué
carpeta buscar o qué herramientas usar: las eligió. Segundo, la
escritura al final está estructurada (set_frontmatter mutando YAML, no
un regex sobre el cuerpo del archivo), así que la nota se redondea limpiamente. La
bóveda autodescriptiva y el grafo de wikilinks están haciendo el trabajo que
hace que esto se sienta natural.
Qué incluye
El servidor expone 20 herramientas MCP en seis áreas.
Búsqueda y descubrimiento
keyword_search(query, folder?, tags?, frontmatter?, limit=20), texto completo vía PostgreSQLtsvector; las configuraciones de búsqueda de texto son configurables víaFTS_CONFIGS(ver Idioma(s) de búsqueda de texto completo)semantic_search(query, folder?, tags?, frontmatter?, limit=15), similitud vectorial vía pgvector, un fragmento de vista previa por notalist_notes(folder?, limit=50), ordenado por tiempo de modificaciónget_recent(folder?, limit=20), cambiados recientementeget_tags(limit=50), etiqueta y recuentoget_vault_guide(), la cartilla de Obsidian más elCLAUDE.mdde esta bóveda, servido en vivo
Lectura y escritura
read_note(path, section?, offset=0, limit?), limitado porMAX_READ_RESPONSE_CHARS(predeterminado 40,000) — ver Límites de tamaño de respuesta.section=<heading>devuelve una sección en lugar de toda la nota;offsetcontinúa una lectura truncada.create_note(path, content), escritura atómica, rechaza sobrescrituraedit_note(path, …)con cuatro modos mutuamente excluyentes: reemplazo completo (predeterminado),append=True,find=…(con opcionalreplace_all), osection=<heading>(encabezados ATX, admiteParent/Childestilo ruta y#Ndesambiguación ordinal).dry_run=Truedevuelve un diff unificado sin escribir. Clientes heredados pueden usaroperation="append";operation="replace"selecciona explícitamente reemplazo completo.move_note(from_path, to_path, rewrite_links=False), reubica y opcionalmente reescribe las referencias entrantes[[Old]],[[Old|alias]],[[Old#anchor]],![[Old]]y[[folder/Old]]en notas de origendelete_note(path, permanent=False), eliminación suave a.trash/<YYYYMMDD-HHMMSS>-<basename>por predeterminado.permanent=Truehace unos.unlinkduro.set_frontmatter(path, updates, remove?), mutación YAML estructurada. El cuerpo es byte-idéntico cuando solo cambia el frontmatter.
Acceso a archivos (no markdown)
Lectura/escritura/navegación en bruto de archivos arbitrarios de la bóveda (PDFs, imágenes, activos de habilidades, archivos de datos) — pares distintos a las herramientas de notas, que permanecen solo-markdown. Transporte de bytes puro: sin extracción de PDF/texto en el servidor, sin incrustación o indexación de archivos no markdown.
read_file(path, encoding="auto", offset=0, limit?), devuelve archivos tipo texto como texto, imágenes como un bloque de imagen en línea que se renderiza en el cliente, y otros binarios como una cadena base64.text/base64fuerzan la forma. Rechaza archivos sobreMAX_FILE_READ_BYTES(predeterminado 10 MB); los resultados de texto están además limitados porMAX_READ_RESPONSE_CHARSy continúan víaoffset.write_file(path, content, encoding="base64", overwrite=False), coloca un archivo en la bóveda; base64 para binario,textpara UTF-8. Sin sobrescritura por predeterminado, crea automáticamente carpetas padre, escritura atómica. Limitado aMAX_FILE_WRITE_BYTES(predeterminado 25 MB).list_files(folder=".", pattern="*", recursive=False, limit=200), navegación estilolsde archivos y subdirectorios con tamaño y mtime, filtrable por glob y con límite de resultados.delete_file(path, permanent=False), elimina suavemente un archivo no markdown a.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex>con un solo renombrado atómico. Rechaza markdown (eso esdelete_note), directorios, y enlaces simbólicos.
Los cuatro reutilizan la protección contra recorrido de rutas y excluyen directorios de puntos
(.obsidian, .git, .trash, …), coincidiendo con la regla de visibilidad del indexador.
Transferencia de archivos
Ningún cliente MCP puede entregar a una herramienta los bytes de un archivo que el usuario está
viendo, así que write_file solo es utilizable cuando el agente ya tiene el
contenido. Estas herramientas cierran esa brecha con enlaces de capacidad de corta duración,
canjeados sobre las rutas públicas /transfer/*.
request_upload(path, overwrite=False, expires_in?), acuña un enlace de un solo uso vinculado exactamente a una ruta de destino. El humano lo abre, elige un archivo, y aterriza enpath— nada más puede ser escrito con él.check_upload(upload_id), informapending/uploading/completed(con tamaño, sha256 y MIME) /expired, limitado a la identidad que lo acuñó.request_download(path, expires_in?), acuña un enlace que el humano puede usar para guardar un archivo de la bóveda. Utilizable más de una vez hasta que expire, y vinculado a los bytes exactos del archivo en el momento de la acuñación.import_from_url(url, path, overwrite=False), obtiene un activo https público directamente en la bóveda bajo una política explícita de denegación de salida (sin direcciones privadas, de bucle local, link-local, de metadatos o tunelizadas, en cualquier ortografía, re-verificadas en cada redirección).
El token viaja en el fragmento de la URL, que los navegadores nunca envían, así que
ningún objetivo de solicitud generado por el servidor o registro de acceso lo contiene. Las subidas
se reclaman antes de que se lea un byte del cuerpo, se publican atómicamente con
semántica de no sobrescritura, y se vinculan en el momento de la acuñación al estado del archivo contra el que
fueron acuñadas — un enlace no puede deshacer silenciosamente una edición hecha mientras
esperaba. MCP_HOSTNAME o BASE_URL deben estar configurados; sin un origen
público, las herramientas de acuñación se niegan en lugar de emitir un enlace de localhost.
Grafo de wikilinks
get_backlinks(path, limit=50), notas que enlazan HACIApathget_links(path), enlaces salientes, tanto resueltos como colgantesget_neighborhood(path, depth=1, limit=50), BFS no dirigido sobre el grafo de enlaces resueltos, limitado a profundidad ≤ 5 y límite ≤ 200find_related(path, limit=10), vecinos semánticos vía incrustaciones de fragmentos promediadas y distancia coseno de pgvector, deduplicados por notafind_orphans(folder?, limit=50), notas con cero enlaces resueltos entrantes o salientes
Autenticación y operaciones
- Claves API con el prefijo
omcp_, almacenadas como hashes SHA-256, con ámbitos de permisoreadyreadwrite. Las herramientas de escritura se niegan en claves de solo lectura. - Flujo OAuth 2.0 PKCE (S256) para clientes públicos y confidenciales, incluyendo ChatGPT, Claude Desktop y claude.ai. El registro dinámico predetermina ambos niveles de permiso de bóveda; el usuario elige la concesión real en la pantalla de consentimiento.
- Panel de control (Jinja2, htmx, Tailwind) para claves, registros de uso, estado del indexador, información del proveedor de incrustaciones y un reinicio de zona de peligro.
- Cada llamada de herramienta se registra en
usage_logscon nombre, parámetros (truncados a 200 caracteres), duración y tamaño de respuesta.
Todas las herramientas de escritura pasan por src/services/vault.py::write_file,
que escribe en un archivo temporal en el mismo directorio y lo os.replace()a
sobre el destino. Un bloqueo a mitad de escritura no puede truncar una nota.
Comparación con otros servidores MCP de Obsidian
Hay varios servidores MCP existentes para Obsidian, y la mayoría resuelven un problema distinto al de este. Los ligeros son pegamento sobre el plugin Local REST API de Obsidian o el sistema de archivos: permiten que un agente acceda a los archivos, pero no construyen infraestructura propia. Son excelentes si el objetivo es "solo quiero que Claude lea mis notas" y mantienes Obsidian ejecutándose localmente.
Este servidor está en el otro extremo del espectro: un backend real con un índice persistente, recuperación semántica, un grafo de wikilinks, OAuth y una interfaz de administración. El costo es Postgres y Docker. El beneficio es todo lo que puedes construir sobre eso.
| Este servidor | MarkusPfundstein/mcp-obsidian | StevenStavrakis/obsidian-mcp | jacksteamdev/obsidian-mcp-tools | |
|---|---|---|---|---|
| Índice persistente (Postgres) | ✅ | — | — | — |
| Búsqueda semántica (vectores) | ✅ | — | — | — |
| Consultas de grafo de wikilinks | ✅ | — | — | parcial |
| Funciona sin Obsidian abierto | ✅ | — | ✅ | — |
| Flujo de cliente OAuth 2.0 | ✅ | — | — | — |
| Multiusuario / bóvedas por usuario | ✅ | — | — | — |
| Interfaz de administración + registros de uso | ✅ | — | — | — |
| Escrituras atómicas + diferencias en seco | ✅ | — | — | — |
| Costo de configuración | Postgres + Docker | Obsidian + plugin REST | Solo Python | Plugin de Obsidian |
La comparación refleja las funciones documentadas de cada proyecto al momento de escribir esto; verifica los detalles antes de apostar por ellos.
Para quién es esto
- Gente de homelab que ya ejecuta Postgres y Docker, o está dispuesta a ponerlos en marcha. El costo de configuración es el precio de entrada para las capas semántica y de grafo.
- Personas que mantienen una bóveda con convenciones propias — lógica de ubicación de tareas, esquemas de frontmatter, taxonomía de etiquetas — y quieren que los agentes sigan esas convenciones en la primera llamada en lugar de recibir instrucciones en cada sesión.
- Cualquiera que ejecute más de un cliente MCP (Claude Desktop, Claude Code, Claude en un navegador, n8n) contra las mismas notas y esté cansado de reexplicar la bóveda a cada uno.
- Personas que quieren que la memoria del agente viva como archivos Markdown planos que puedan leer, editar, buscar con grep y controlar con versiones, no en un almacén de vectores opaco ni en un servicio de memoria administrado.
Para quién no es esto
- "Solo quiero que Claude lea mis notas" con la configuración más ligera posible. Usa uno de los proyectos de pegamento de archivos mencionados arriba; no necesitas esto.
- Cualquiera que no esté dispuesto a ejecutar una base de datos. No hay alternativa con SQLite; pgvector hace trabajo real, y un Postgres administrado con soporte de pgvector es parte de la pila.
- Personas que quieren un producto alojado llave en mano. Este es un servidor autoalojado que ejecutas tú mismo.
Panel de control
El servidor incluye una interfaz de administración integrada para las partes de las operaciones que son más fáciles de ver que de consultar: generar claves, vigilar el indexador, revisar el tráfico de llamadas a herramientas y restablecer los embeddings cuando cambias de proveedor.
Uso
Registro de auditoría por llamada a herramienta con un histograma de solicitudes de 14 días. Cada llamada MCP se registra con la clave que la realizó, el nombre de la herramienta, la duración y el tamaño de la respuesta — útil para detectar un agente con mal comportamiento que gasta tokens en algo que no debería.

Claves API y clientes OAuth
Claves Bearer con alcances read / readwrite para clientes API, y un
flujo OAuth 2.0 PKCE separado para clientes como ChatGPT, Claude Desktop
y claude.ai que esperan un baile de código de autorización adecuado. El
servidor OAuth admite autenticación de punto final de token pública
(none) y confidencial (client_secret_post), además de tokens de
actualización.

Explorador de bóveda
Un árbol de archivos de solo lectura de la bóveda montada, principalmente para verificar que el contenedor ve lo que crees que ve.

Configuración
Estado del indexador, proveedor y modelo de embeddings actuales, ruta de la bóveda y la zona de peligro de restablecimiento que elimina y recrea la columna de embeddings en la dimensión configurada. Úsalo al cambiar de proveedor.

Inicio rápido
¿Desplegando en un VPS desde cero? Consulta
DEPLOYMENT.mdpara el tutorial completo: configuración de Postgres, Caddy y TLS, sincronización de bóveda mediante Nextcloud y los problemas que afectan a los primeros despliegues.
La configuración de Caddy incluida falla de forma segura en /admin, /api y
/authorize; reemplaza su hash de autenticación básica de marcador de
posición antes de iniciarlo.
Requisitos previos
- Docker y Docker Compose
- Una instancia de PostgreSQL 16 accesible desde el contenedor, con la
extensión
pgvectorinstalada - Una instancia de Ollama ejecutando
bge-m3, o una clave API de OpenAI. Cualquier cosa que hable el protocolo de embeddings de OpenAI funciona (Azure OpenAI, OpenRouter, Together, etc.).
1. Clona, configura, apunta a tu bóveda
git clone https://github.com/maxkuminov/obsidian-mcp.git
cd obsidian-mcp
cp .env.example .env
$EDITOR .env
En docker-compose.yml, apunta el volumen /obsidian a tu bóveda:
volumes:
- /path/to/your/vault:/obsidian
2. Elige un backend de embeddings
Opción A, OpenAI (cero infraestructura local):
EMBEDDING_PROVIDER=openai
OPENAI_API_KEY=sk-...
EMBEDDING_DIMENSIONS=1024
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
El servidor valida OPENAI_API_KEY al iniciar y se niega a arrancar
si falta.
Opción B, Ollama (autoalojado, GPU recomendada):
EMBEDDING_PROVIDER=ollama
OLLAMA_URL=http://your-ollama-host:11434
EMBEDDING_MODEL=bge-m3
EMBEDDING_DIMENSIONS=1024
Este es el predeterminado. Omitir EMBEDDING_PROVIDER recurre a
Ollama.
3. Despliega
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
El primer despliegue rellena el índice, el grafo de wikilinks y los
embeddings. Para una bóveda de 2 a 3 mil notas en Ollama con GPU, esto
toma unos minutos. En text-embedding-3-small son segundos.
4. Conecta un cliente
Genera una clave API en el panel de control y luego apunta tu cliente MCP a:
URL: https://obsidian-mcp.<your-domain>/mcp
Auth: Bearer omcp_...
Para Claude Desktop, agrega a 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_..."
Lo primero que cualquier agente debería hacer en una sesión nueva es
llamar a get_vault_guide(). Así aprende tu estructura de carpetas,
convenciones de nombres y esquema YAML antes de escribir nada.
Expectativas de costo
Si eliges la ruta de OpenAI (el camino realista en un VPS solo con CPU), el gasto del primer índice es pequeño y el estado estable es casi gratis. Números aproximados asumiendo una nota promedio de alrededor de 1,500 tokens (tres fragmentos de 512 tokens), a la tarifa publicada de OpenAI al momento de escribir esto:
| 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 |
Después del primer índice, solo las notas modificadas se vuelven a incrustar. El costo continuo es proporcional a las ediciones — centavos al mes para una bóveda típica.
Si autoalojas Ollama con GPU, el costo de embeddings es lo que diga tu factura de electricidad. Ollama en CPU funciona pero es demasiado lento para ser utilizable en una bóveda de más de unos cientos de notas.
La bóveda autodescriptiva
Esta es la parte que la mayoría de los proyectos "MCP para Obsidian" pasan por alto. Se detienen en leer, escribir y listar. La pregunta interesante no es "¿puede el agente acceder a los archivos?", sino "¿conoce el agente las reglas?"
Si tienes una bóveda con convenciones propias — lógica de ubicación de tareas, convenciones de carpetas, frontmatter obligatorio, taxonomía de etiquetas — un agente con acceso de escritura puede causar daños reales sin ese contexto. Las tareas terminan en la carpeta equivocada. Los nombres de archivo con fecha desnuda chocan con las plantillas. Las etiquetas incorrectas rompen las consultas de Dataview. La capa de datos funciona bien; la capa de contexto es donde aparecen las fallas.
La solución es pequeña. Mantén un archivo de instrucciones legible por
máquina (CLAUDE.md en la raíz de la bóveda) que describa las reglas
propias del sistema. Exponlo como una herramienta dedicada. Cada agente
que se conecta lo llama una vez al inicio de una sesión e inmediatamente
sabe cómo funciona la bóveda. Actualiza el archivo y cada agente verá el
cambio en la siguiente llamada. Sin configuración del lado del cliente.
Sin inyección de prompt del sistema. La bóveda es la autoridad sobre sus
propias reglas.
get_vault_guide() hace exactamente esto. Devuelve una introducción genérica
de Obsidian (sintaxis de wikilinks, sintaxis de incrustación,
convenciones de etiquetas, literales comunes de plugins) más el
CLAUDE.md de la bóveda en vivo. La sugerencia de llamarlo primero
está integrada en las descripciones de las herramientas de escritura,
de modo que el agente se vea llevado al comportamiento correcto incluso
sin instrucciones.
Modo multiusuario
El modo de usuario único es el predeterminado y funciona exactamente como se describió arriba: una bóveda, un conjunto de claves API, sin concepto de usuario en la aplicación. El modo multiusuario es una bandera opcional que convierte el mismo contenedor en un pequeño despliegue multiinquilino: inicio de sesión con nombre de usuario y contraseña en la aplicación, alcance de bóveda por usuario, un rol de administrador para solución de problemas y un rol de usuario regular que solo ve sus propias claves/clientes OAuth/uso. Un contenedor, un Postgres, aislamiento estricto entre usuarios.
Actívalo en un despliegue existente sin pérdida de datos: tu bóveda actual y tus claves se transfieren al administrador de arranque.
Activación
- Establece
MULTI_USER_MODE=truey unaSECRET_KEYsegura en.env(openssl rand -hex 32está bien). La aplicación se niega a iniciar con el valor de marcador de posición cuando la bandera está activada. make deploy(odocker compose up -d --force-recreate).- Visita el panel. Como la tabla
usersestá vacía, se te dirige a/admin/register— el formulario de arranque de una sola vez. Sigue detrás del middlewarechain-oauth@filede Traefik, así que solo las personas en las que Traefik ya confía pueden reclamar la administración. - Regístrate con un nombre de usuario y contraseña elegidos. El
formulario de arranque precompleta
vault_pathcon lo que se haya establecido enVAULT_PATH, de modo que tus notas existentes pertenezcan inmediatamente a este nuevo administrador. Sin reindexación, sin reinserción de embeddings, sin pérdida de datos — cada nota previamente indexada, clave API, cliente OAuth y fila de registro de uso se rellena retroactivamente al usuario de arranque en una sola transacción.
Invitar usuarios
-
Edita
docker-compose.ymlpara agregar un montaje de volumen para la bóveda del nuevo usuario en/vaults/<username>. Las rutas de host con espacios deben citarse como una sola cadena YAML:volumes: - "/storage/vaults/alice:/vaults/alice" - "/storage/shared/bob/Obsidian:/vaults/bob"make deploypara aplicar. -
En el panel,
/admin/users/create— elige un nombre de usuario y establece una contraseña inicial. -
/admin/users/{id}/edit— establece elvault_pathdel usuario a la ruta del contenedor que acabas de montar (por ejemplo,/vaults/bob). El formulario muestra un menú desplegable de directorios/vaults/*no asignados que existen en el disco. -
Comparte las credenciales fuera de banda. El usuario inicia sesión en
/admin/auth/login, obtiene sus propias vistas de claves/OAuth/uso y no puede ver las notas de otros usuarios.
Lo que ven los administradores
Los administradores ven claves API, clientes OAuth y registros de uso de
todos los usuarios; son dueños de la página de Configuración (proveedor
de embeddings, disparador del indexador, zona de peligro) y de la página
de Usuarios. Los administradores no navegan por el contenido de las
bóvedas de otros usuarios a través del panel — eso es intencional.
Solucionar problemas en la bóveda de otro usuario significa inspeccionarla
mediante docker exec o reasignar temporalmente su vault_path,
no espiar por la interfaz.
Reversión
Establece MULTI_USER_MODE=false y reinicia. Las claves API existentes siguen
funcionando (los filtros por usuario se omiten cuando no hay contexto de
usuario establecido), la interfaz de inicio de sesión y las cookies de
sesión desaparecen, y el panel vuelve a su modo solo-OAuth-de-Traefik.
El esquema permanece en su lugar, así que volver al modo multiusuario
más tarde se reanuda donde lo dejaste sin volver a arrancar (la tabla
users no está vacía, así que /admin/register está cerrado).
Restricciones y límites conocidos
- El indexador itera sobre los usuarios activos secuencialmente en cada ciclo. Está bien para decenas de usuarios; cientos requerirían paralelización.
- El restablecimiento de contraseña solo lo impulsa el administrador — no hay flujo de autoservicio por correo electrónico.
- Sin límite de velocidad en
/admin/auth/login. La puerta OAuth de Traefik frente al panel es la principal defensa contra fuerza bruta; si expones/admin/auth/logina internet abierto, coloca un middleware de límite de velocidad delante. - El validador de
vault_pathno resuelve enlaces simbólicos, así que un administrador técnicamente puede apuntar a un usuario a archivos del host mediante un/vaults/<name>con enlace simbólico. Trata/vaults/como un límite de confianza de administrador.
Configuración
| Variable | Valor por defecto | Propósito |
|---|---|---|
DATABASE_URL | — | postgresql+asyncpg://user:pass@host/db |
VAULT_PATH | /obsidian | Montaje del vault en el contenedor |
SECRET_KEY | — | Clave del firmante de itsdangerous |
INDEX_INTERVAL_SECONDS | 300 | Cadencia del reindexado periódico |
MAX_FILE_READ_BYTES | 10485760 | Límite de read_file (10 MB); acota lo que el servidor lee del disco |
MAX_FILE_WRITE_BYTES | 26214400 | Límite de write_file (25 MB), longitud de bytes decodificados |
MAX_READ_RESPONSE_CHARS | 40000 | Límite de read_note / read_file sobre lo que se devuelve al llamante (≈10K tokens). Ver Límites de tamaño de respuesta. |
FTS_CONFIGS | english | Configuración(es) de búsqueda de texto para búsqueda por palabras clave. JSON o CSV. Ver Idioma(s) de búsqueda de texto completo. |
TRANSFER_TOKEN_TTL_SECONDS | 600 | Vida útil por defecto de un enlace de transferencia. El expires_in por llamada se limita a 60–3600. |
TRANSFER_MAX_UPLOAD_SECONDS | 600 | Cuánto tiempo puede transmitir una subida reclamada antes de que el token se consuma |
TRANSFER_MAX_CONCURRENT_UPLOADS | 4 | Transmisiones de subida simultáneas |
IMPORT_ALLOW_HTTP | false | Permitir que import_from_url obtenga http plano. Desactivado por defecto. |
EMBEDDING_PROVIDER | ollama | ollama o openai |
EMBEDDING_DIMENSIONS | 1024 | Ancho de columna de pgvector |
OLLAMA_URL | — | Se usa cuando el proveedor es Ollama |
EMBEDDING_MODEL | bge-m3 | Nombre del modelo de Ollama |
OPENAI_API_KEY | — | Requerido cuando el proveedor es OpenAI |
OPENAI_BASE_URL | https://api.openai.com/v1 | Anulación para Azure o proxies |
OPENAI_EMBEDDING_MODEL | text-embedding-3-small | Modelo de OpenAI |
CHUNK_SIZE | 512 | Tokens aproximados por fragmento (heurística de 4 caracteres) |
CHUNK_OVERLAP | 0 | Solapamiento de tokens entre fragmentos |
MCP_SANDBOX_MODE | false | Solo evaluación de registro. Omite la base de datos, el indexador, el proveedor de incrustaciones y la autenticación de /mcp para que la introspección funcione sin dependencias externas. No habilitar en producción. |
Ver .env.example para el conjunto completo con comentarios. Para el
gasto del primer índice en OpenAI, ver Expectativas de coste arriba.
Cambio de proveedores
Diferentes modelos producen vectores no comparables, por lo que un cambio de proveedor requiere reindexar.
make down- Actualiza
.env. CambiaEMBEDDING_PROVIDER, establece las credenciales, opcionalmente cambiaEMBEDDING_DIMENSIONS. make reset-embeddingsmake up. La siguiente pasada del indexador vuelve a incrustar el vault.
También puedes usar Ajustes → Zona de peligro → Restablecer incrustaciones en el panel de control, que realiza el mismo SQL mientras el servidor está en ejecución (pausa el indexador, ejecuta el SQL, reanuda).
Si cambias EMBEDDING_DIMENSIONS sin ejecutar el restablecimiento, el
servidor detecta el desajuste al iniciar y sale con código distinto de cero con un
puntero al objetivo de restablecimiento.
Idioma(s) de búsqueda de texto completo
keyword_search se ejecuta sobre un tsvector de PostgreSQL. La configuración de
búsqueda de texto que utiliza — el stemmer y el diccionario de palabras vacías — está
controlada por FTS_CONFIGS. Por defecto es english, que reproduce
el comportamiento histórico exactamente, por lo que las implementaciones existentes no necesitan ninguna acción.
FTS_CONFIGS es una lista, configurable como JSON
(FTS_CONFIGS=["simple","norwegian"]) o separada por comas
(FTS_CONFIGS=simple,norwegian). Cada nota se indexa bajo cada
configuración listada, y una consulta coincide si el análisis de cualquier configuración listada acierta.
Esto es lo que hace funcionar un vault de idiomas mixtos:
FTS_CONFIGS | Comportamiento |
|---|---|
english | Stemmer Snowball de inglés (por defecto; running ↔ run). |
simple | Agnóstico al idioma. Sin stemming ni palabras vacías — coincide con formas exactas de palabras. Un valor por defecto con principios para vaults de idiomas mixtos: la búsqueda por palabras clave es el brazo de coincidencia exacta, mientras que semantic_search (bge-m3 es multilingüe) maneja el recuerdo morfológico. |
english,norwegian | Ambos stemmers aplicados — morfología del lado de palabras clave para dos idiomas a la vez. |
simple,norwegian | Lexemas verbatim más stems de noruego. |
El ajuste es global — se aplica a cada vault (consistente con
EMBEDDING_MODEL, CHUNK_SIZE, etc., que también son globales). Para una
instancia multiusuario de idiomas mixtos, establece un superconjunto (p. ej.
["english","norwegian"], o ["simple"]). La configuración FTS por usuario es una
extensión futura limpia pero no está implementada.
Un nombre de configuración mal escrito o no instalado falla rápidamente al iniciar con un mensaje que lista las configuraciones disponibles en tu instancia de Postgres, en lugar de producir búsquedas silenciosas con cero resultados.
Cambiar FTS_CONFIGS requiere una reconstrucción. Los tsvectors almacenados se
calculan en el momento de la indexación, por lo que quedan obsoletos cuando cambia la lista de configuraciones.
Después de editar .env y volver a implementar, ejecuta:
make rebuild-tsvectors
Esto relee cada nota y recalcula su content_tsvector bajo las
nuevas configuraciones. Reconstruye solo el índice de palabras clave — no
toca las incrustaciones/vectores y no hace llamadas API, por lo que termina en
segundos para unos pocos miles de notas. (No lo confundas con el costoso
flujo de make reset-embeddings.)
Advertencia de tokenización: el analizador de tsvector todavía divide en puntuación y guiones independientemente de la configuración, por lo que
bge-m3se tokeniza enbge+m3.simplepreserva formas de palabras, no cadenas con puntuación; la coincidencia exacta de cadenas con puntuación necesitaría un índice trigrama y está fuera de alcance.
Límites de tamaño de respuesta
Un resultado de herramienta es entrada del modelo. Lo que read_note devuelve se alimenta
directamente en la siguiente solicitud del llamante, por lo que una lectura sin límite es
un prompt sin límite — y el llamante normalmente se entera solo cuando su
proveedor de inferencia rechaza la solicitud.
MAX_READ_RESPONSE_CHARS (por defecto 40,000, aproximadamente 10K tokens) limita
lo que read_note y los resultados de texto de read_file devuelven. Es un
límite diferente de MAX_FILE_READ_BYTES, que limita lo que el
servidor lee del disco. Una nota de 3 MB está cómodamente dentro del límite de lectura de 10 MB
y aun así destruirá una ventana de contexto; ambos límites son necesarios y
tienen valores correctos diferentes.
Se aplica por componente, no una vez a toda la respuesta: la
ventana de contenido recibe el límite, y el esquema de encabezados lo recibe
independientemente. Una lectura truncada puede llevar ambos, así que presupuesta para un
peor caso de aproximadamente 2 × MAX_READ_RESPONSE_CHARS más unos pocos cientos
de caracteres de texto de aviso fijo — no el valor de un solo límite.
Cuando una nota excede el límite, obtienes la primera ventana más un
aviso de [TRUNCATED] que lleva el offset exacto para continuar, y
— para una lectura de nota completa — un esquema de las secciones de la 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 una nota de varios megabytes de 40K a la vez es técnicamente posible y
prácticamente inútil, así que prefiere el esquema: lee la única sección que
quieras con read_note(path, section="Lease Agreement.pdf"). Las secciones son
direccionables de tres maneras — el ordinal de #N mostrado en el esquema, la
forma de estilo de ruta de Parent/Child, y el texto exacto del encabezado. El ordinal es
la única forma que separa encabezados duplicados hermanos, que comparten
cada ancestro y por lo tanto no pueden desambiguarse por ruta; las notas
generadas por extracción masiva tienden a estar llenas de ellos.
Un #N simple siempre selecciona por posición, por lo que un ordinal que te damos en
un esquema nunca puede ser ensombrecido por un encabezado que casualmente se titula
#2. Tal encabezado sigue siendo accesible a través de la forma de ruta (Parent/#2) o
a través de su propio ordinal.
El esquema está a su vez limitado por el límite: una nota con miles de encabezados obtiene un listado truncado que informa cuántas secciones se omitieron y el rango ordinal completo, en lugar de un esquema más grande que la ventana de contenido que acompaña.
limit puede bajar el límite para una sola llamada pero nunca subirlo. Si tus
clientes realmente quieren lecturas más grandes, sube MAX_READ_RESPONSE_CHARS —
esa es una decisión del operador, tomada una vez, por alguien que conoce la
implementación.
Actualización: este es un cambio de contrato visible. Antes de esto, un
read_noteen una nota grande devolvía todo; ahora trunca. El aviso es autodescriptivo, por lo que un agente no necesita conocimiento previo para continuar, pero un script que asumía lecturas de nota completa debería pasarsection=o subir el límite.
Arquitectura
┌──────────────┐ ┌──────────────────────┐
│ 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 indexación
.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
El indexador se ejecuta al iniciar y cada INDEX_INTERVAL_SECONDS (5
minutos por defecto). Los hashes son solo de contenido, por lo que el detector de cambios
ignora la fluctuación de mtime. Las incrustaciones obsoletas se detectan por el
desajuste de embedded_content_hash != content_hash.
Esquema de base de datos
| Tabla | Propósito |
|---|---|
notes_metadata | Ruta, título, etiquetas, frontmatter, hash de contenido, hash incrustado, tsvector, hora de modificación |
note_embeddings | Una fila por fragmento. embedding es vector(EMBEDDING_DIMENSIONS). |
note_links | Grafo de wikilinks: IDs de origen/destino, target_path, tipo (link, embed, markdown) |
api_keys | Tokens de portador con hash, prefijo para visualización, permiso, caducidad |
usage_logs | Auditoría por llamada de herramienta |
oauth_clients, oauth_codes, oauth_tokens | Estado de OAuth 2.0 PKCE |
Índices GIN en content_tsvector y tags[]. Índices B-tree en las
claves foráneas calientes. Índice HNSW de pgvector en la columna de incrustación
(vector_cosine_ops, m=16, ef_construction=64); las consultas establecen
hnsw.ef_search=80 y deduplican por nota en Python después de una sobrecarga de 5x.
Estructura del proyecto
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)
Desarrollo
pip install -r requirements-dev.txt
pytest
La suite de pruebas unitarias cubre la abstracción del proveedor de incrustaciones, el
agrupamiento y comportamiento de reintentos de OpenAI, la validación de configuración, y la
verificación de inicio de desajuste de dimensiones. Las pruebas vinculadas a la red usan respx para
simular httpx, por lo que no se requiere acceso real a la red.
Para ejecutar el servidor fuera de Docker:
DATABASE_URL=... SECRET_KEY=... VAULT_PATH=... uvicorn src.main:app --reload
Objetivos de 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 seguridad
- Las claves API usan el prefijo
omcp_y se almacenan como hashes SHA-256. La clave cruda se muestra exactamente una vez en la creación. - El panel de control está destinado a estar detrás de una puerta de enlace de autenticación
externa. El
docker-compose.ymlincluido usa Traefik con una cadena OAuth. No expongas/admindirectamente a internet. - La clave de OpenAI se muestra en la página de ajustes como
key[:8] + "..." + key[-4:]y nunca aparece completa en HTML o fuentes JS. - El recorrido de rutas está bloqueado en la capa de servicio. Todas las rutas de escritura
se resuelven a través de
Path.resolve().relative_to(vault_root). - Consultas parametrizadas en todas partes. Sin interpolación de cadenas en SQL.
- Los encabezados de respuesta incluyen HSTS,
X-Content-Type-Options: nosniff, yX-Frame-Options: DENY.
Estado
De un solo autor, en uso activo como exocórtex personal del mantenedor (2,500+ notas, múltiples agentes conectados). Público para cualquiera que quiera bifurcarlo. Issues y PRs bienvenidos pero espera una revisión con opiniones. Este es un sistema funcional, no una plataforma genérica.
Licencia
MIT. Ver LICENSE.