Skip to main content
Glama
sangwookp9591

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` |