obsidian-local-mcp-server
README.md
# obsidian-local-mcp-server
Obsidian vault를 **MCP 서버**로 노출해 Claude Code와 Codex에서 언제든 검색·조회·편집할 수 있게 한다.
서버는 Docker 컨테이너로 돌고, vault는 호스트 폴더를 볼륨으로 마운트한다.
MCP **2026-07-28** 명세(Stateless HTTP)를 구현했다. 세션도 `initialize` 핸드셰이크도 없이
요청 하나하나가 독립적으로 완결되므로, 컨테이너 한 대에 여러 클라이언트가 자유롭게 붙는다.
```
호스트 Docker
┌──────────────┐
│ Claude Code │──┐
├──────────────┤ │ 127.0.0.1:8787 ┌─────────────────────────┐
│ Codex │──┼──────────────────▶│ obsidian-mcp (non-root) │
├──────────────┤ │ Stateless HTTP │ Stateless HTTP :8787 │
│ 그 외 MCP │──┘ └───────────┬─────────────┘
│ 클라이언트 │ │ volume
└──────────────┘ ▼
┌───────────────────────────┐
│ 호스트의 Obsidian vault │
│ (VAULT_PATH → /vault) │
└───────────────────────────┘
```
---
## 1. 빠른 시작
```bash
git clone <이 저장소>
cd obsidian-local-mcp-server
cp .env.example .env # vault 경로를 여기서 지정한다 (2절 참고)
npm run up # 이미지 빌드 + 컨테이너 기동
npm run health # {"ok":true,"notes":652,"vault":"/vault"}
```
그다음 클라이언트를 붙인다.
```bash
# Claude Code (모든 프로젝트에서 사용)
claude mcp add --transport http --scope user obsidian-vault http://127.0.0.1:8787/mcp
# Codex
codex mcp add obsidian-vault --url http://127.0.0.1:8787/mcp
```
두 클라이언트 모두 **새 세션부터** 도구가 보인다. MCP 서버 목록은 세션 시작 시 로드된다.
---
## 2. 최초 vault 폴더 등록
vault 경로는 `.env`의 `VAULT_PATH` 하나로 정해진다. 이미지에 노트를 굽지 않고
호스트 폴더를 그대로 마운트하므로, Obsidian에서 고친 내용이 즉시 서버에 보이고
MCP 쓰기 도구의 결과도 호스트 파일에 그대로 남는다.
```bash
cp .env.example .env
```
`.env`를 열어 세 값을 채운다.
```bash
# 연결할 Obsidian vault의 절대경로 (필수)
VAULT_PATH=/Users/iron/Project/ai-docs/vault
# 호스트에 노출할 포트 (기본 8787)
MCP_PORT=8787
# 마운트된 vault를 읽고 쓰려면 호스트 사용자와 UID를 맞춰야 한다
UID=501
GID=20
```
`UID`/`GID`는 아래로 확인한다. 이 값이 틀리면 읽기는 되는데 **쓰기 도구만 권한 오류**가 난다.
```bash
echo "UID=$(id -u)"
echo "GID=$(id -g)"
```
경로를 바꿨으면 컨테이너를 다시 만든다. `restart`가 아니라 `up`이어야 마운트가 새로 잡힌다.
```bash
npm run down && npm run up
npm run health # notes 개수로 새 vault가 잡혔는지 확인
```
> **vault를 여러 개 쓰려면** `.env`의 `MCP_PORT`를 다르게 준 복사본으로 컨테이너를 하나 더
> 띄우고, 클라이언트에 다른 이름으로 등록하면 된다.
---
## 3. 서버 실행 방법
### 평소 운영 (Docker Compose)
```bash
npm run up # 빌드 + 기동 (docker compose up -d --build)
npm run down # 중지 및 컨테이너 제거
npm run restart # 재시작 (코드 변경은 반영되지 않는다 — up을 써야 한다)
npm run logs # 로그 추적
npm run health # 상태 확인
docker compose ps # 컨테이너 상태 (healthy 여부)
```
`restart: unless-stopped` 정책이라 Docker Desktop이 켜지면 컨테이너도 함께 올라온다.
직접 `npm run down`으로 내리기 전까지는 계속 살아 있다.
**코드를 고쳤다면 `npm run up`으로 다시 빌드해야 반영된다.** `restart`는 이미지가 아니라
컨테이너만 다시 띄우므로 옛 코드가 그대로 돈다.
### stdio로 붙이고 싶다면
HTTP 대신 클라이언트가 컨테이너를 직접 띄우는 방식도 지원한다.
상시 데몬이 필요 없는 대신 호출 때마다 컨테이너가 새로 뜬다.
```bash
claude mcp add --scope user obsidian-vault -- \
docker run -i --rm -v /Users/iron/Project/ai-docs/vault:/vault \
-e OBSIDIAN_VAULT=/vault obsidian-vault-mcp:latest node dist/stdio.js
```
### Docker 없이 (개발용)
```bash
npm ci && npm run build
node dist/http.js # http://127.0.0.1:8787/mcp
node dist/stdio.js # stdio
```
---
## 4. 아키텍처와 프로토콜
### 프로토콜: MCP 2026-07-28 Stateless
`@modelcontextprotocol/server` v2로 구현했다. 이 개정판의 핵심은 **세션 제거**다.
| 항목 | 2025 이전 | 2026-07-28 |
| --- | --- | --- |
| 연결 개시 | `initialize` / `notifications/initialized` | 없음 — 요청이 스스로 완결 |
| 세션 | `Mcp-Session-Id` 헤더 | 제거 |
| 능력 확인 | 핸드셰이크에서 1회 | `server/discover` (선택) + 요청별 `_meta` |
| 서버 상태 | 세션에 암묵적으로 보관 | 명시적 핸들을 인자로 전달 |
요청은 이런 모양이 된다.
```http
POST /mcp HTTP/1.1
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: vault_search
```
`Mcp-Method`와 `Mcp-Name`이 헤더로 올라와, 게이트웨이가 본문을 파싱하지 않고도
라우팅·인가·레이트리밋을 걸 수 있다.
이 설계가 컨테이너와 잘 맞는 이유는 **서버가 요청 사이에 아무것도 기억하지 않기 때문**이다.
Claude Code와 Codex가 같은 컨테이너에 동시에 붙어도 서로 간섭하지 않고,
컨테이너를 재시작해도 클라이언트가 세션을 잃지 않는다.
### 한 factory가 두 전송을 덮는다
```
buildServer(index) ← 도구 정의는 여기 한 곳뿐
╱ ╲
serveStdio(factory) createMcpHandler(factory)
│ │
stdio Stateless HTTP
(docker run -i) (docker compose, :8787)
```
`src/server.ts`의 factory 하나를 두 진입점이 공유하므로 전송별로 도구가 갈라질 수 없다.
2025 era 클라이언트가 붙으면 SDK가 stateless fallback으로 알아서 받아준다.
### 레이어
| 파일 | 역할 |
| --- | --- |
| `src/vault.ts` | vault 인덱스 · 파싱 · 검색 · 링크 그래프 · 경로 보안 |
| `src/server.ts` | MCP 서버 factory (도구 8개). 인덱스를 **주입받는다** |
| `src/stdio.ts` | stdio 진입점 |
| `src/http.ts` | Stateless HTTP 진입점 + Host/Origin 검증 + `/health` |
`buildServer(index)`가 인덱스를 주입받는 구조라서 테스트가 임시 vault를 꽂아 넣을 수 있다.
모듈 전역 인덱스였다면 도구 테스트가 실제 vault를 건드려야 했을 것이다.
### 인덱싱
652파일 0.8MB 전체 스캔이 ~150ms라 역색인을 유지할 이유가 없다. 통째로 메모리에 올리고,
TTL 5초로 외부 변경(Obsidian에서 직접 편집한 것)을 따라잡는다. 쓰기 도구는 즉시
`invalidate()`하므로 방금 쓴 노트가 다음 검색에 바로 잡힌다.
### 보안
명세는 두 가지를 요구한다: **Origin 헤더 검증 후 403**, 그리고 **로컬 배포는 localhost에만 바인드**.
- `createMcpHandler`는 의도적으로 검증을 하지 않으므로 `src/http.ts`가 앞단에서
Host/Origin을 확인하고 **403**을 낸다. 이게 없으면 사용자가 열어둔 아무 웹페이지나
`localhost:8787`로 요청을 보내 vault를 읽고 **쓸 수** 있다(DNS rebinding).
- 컨테이너 내부는 `0.0.0.0`에 바인드할 수밖에 없다(그래야 포트 매핑이 닿는다).
루프백 제한은 호스트 매핑 `127.0.0.1:8787:8787`이 담당한다. compose에서 앞의
`127.0.0.1`을 빼면 **같은 네트워크의 누구나 vault를 편집할 수 있게 되므로 빼지 말 것.**
- 컨테이너는 non-root로 돈다. 쓰기 경로는 전부 `safePath()`를 통과해
vault 밖(`../`, 절대경로)과 숨김 경로를 막는다.
---
## 5. MCP 주요 기능 — 도구 8개
### 읽기
| 도구 | 하는 일 | 주요 인자 |
| --- | --- | --- |
| `vault_search` | 제목·별칭·태그·본문을 가중치로 채점해 검색 | `query`, `type`, `tag`, `folder`, `limit`, `full` |
| `vault_read` | 노트 전문 읽기 + 연결 관계 동봉 | `ref`, `with_links` |
| `vault_links` | 백링크(참조하는 노트) / 아웃링크(참조되는 노트) | `ref`, `direction` |
| `vault_browse` | 전체 개요, 태그별·타입별 목록, 주제 맵(MOC) 열기 | `tag`, `moc`, `type`, `limit` |
| `vault_sources` | 개념 → 강의 유튜브 타임스탬프 링크 | `ref` |
`ref`는 **제목·별칭·경로 아무거나** 받는다. `"환경 변수"`, `"env"`, `".env"`,
`"10-Concepts/환경 변수.md"`가 모두 같은 노트로 해석된다.
검색 점수는 제목 정확일치(100) > 별칭 정확일치(80) > 제목 부분일치(50) >
별칭 부분일치(30) > 태그(25) > 본문 빈도(최대 20) 순으로 쌓인다.
`vault_sources`는 개념 노트의 `sources` frontmatter를 읽어 강의 영상의 **해당 지점**으로
바로 가는 링크를 만든다.
```
**그래프 엔지니어링** 강의 출처 3건
- [13:39] **Loop와 Graph는 무엇이 다른가?**
https://youtu.be/I_c8R_PckJ8?t=819
영상 노트: `20-Videos/2026-08-31 암묵지 자산화를 위한 딥트윈 에이전트의 전체 원리와 설계 프로세스.md`
```
### 쓰기
| 도구 | 하는 일 | 안전장치 |
| --- | --- | --- |
| `vault_write` | 노트 생성/덮어쓰기 | 기존 노트는 `overwrite=true` 없이 못 덮는다 |
| `vault_append` | 기존 노트에 섹션 추가 | 기존 내용을 지우지 않는다 |
| `vault_link` | 노트 간 위키링크 `[[대상]]` 연결 | 이미 있으면 아무것도 바꾸지 않는다 |
도구마다 MCP 어노테이션을 붙여 클라이언트가 위험도를 구분할 수 있게 했다 —
읽기 5개는 `readOnlyHint`, `vault_write`는 `destructiveHint`, `vault_link`는 `idempotentHint`.
---
## 6. 개발
```bash
npm test # 전체 100개 (Docker 통합 포함, 이미지 빌드 수행)
npm run test:unit # Docker 제외 — 빠른 반복용
npm run typecheck
npm run build
```
TDD로 만들었다. 테스트는 네 층이다.
| 파일 | 검증 대상 |
| --- | --- |
| `test/vault.test.ts` | 인덱스 단위 + **실제 vault 통합**(위키링크 100% 해석) |
| `test/tools.test.ts` | `InMemoryTransport`로 MCP 왕복 — 도구 8개 입출력 |
| `test/transports.test.ts` | 실제 포트 · 실제 프로세스 spawn · Host/Origin 403 |
| `test/docker.test.ts` | 이미지 빌드 · 볼륨 양방향 · non-root · 루프백 · compose 설정 |
Docker 테스트는 데몬이 없으면 자동으로 skip된다. 쓰기 테스트는 전부 임시 vault에서만
돌므로 실제 vault는 건드리지 않는다.
### 설계 메모
- **NFC 정규화가 핵심이다.** macOS는 한글 파일명을 NFD(자모 분리)로 저장하지만
본문의 위키링크는 NFC다. 정규화 없이는 `[[환경 변수]]`가 파일에 매칭되지 않는다.
실제 vault의 위키링크 3,990개가 100% 해석되는지를 테스트로 못박아 두었다.
- **`safePath()`는 선행 슬래시를 벗기지 않는다.** 벗기면 `/etc/hosts`가
`<vault>/etc/hosts.md`로 둔갑해 의도와 다른 곳에 쓰인다. 원문 그대로 `resolve()`해서
절대경로가 절대경로로 판정되게 둔다. (TDD로 잡은 버그다.)
- **`fetch()`로는 Host 헤더를 위조할 수 없다.** Node가 금지 헤더로 보고 무시하므로,
DNS rebinding 방어를 시험하려면 `node:http` raw 요청이어야 한다.
이걸 모르고 쓴 첫 테스트는 406을 403으로 착각해 통과하고 있었다.
---
## 환경변수
| 변수 | 기본값 | 설명 |
| --- | --- | --- |
| `VAULT_PATH` | — | (compose) 호스트 vault 절대경로. **필수** |
| `MCP_PORT` | `8787` | (compose) 호스트에 노출할 포트 |
| `UID` / `GID` | `501` / `20` | (compose) 컨테이너 실행 사용자 |
| `OBSIDIAN_VAULT` | `/vault` | 서버가 읽을 vault 경로 |
| `OBSIDIAN_MCP_PORT` | `8787` | 서버 리슨 포트 |
| `OBSIDIAN_MCP_HOST` | `127.0.0.1` | 바인드 주소. 컨테이너에서는 `0.0.0.0` |
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues