# MCP Server 진단 및 개선 방안

## 분석 개요
- 분석 대상: `mcp-server/server.py` (1608줄), `worker/` 하위 워커 스크립트
- 진단 일자: 2026-05-08
- 수정 완료: 2026-05-08
- 분석 범위: FastMCP tool 등록 동작 검증, 워커 아키텍처 동작 확인, 병목 지점 식별

---

## 1. 진단 결과 (검증 완료)

### 1.1 [HIGH] 워커 아키텍처 전체 무효화 ✅ 수정 완료

**문제**: `server.py`의 1398-1592줄(워커 포워딩 래퍼)이 FastMCP의 중복 등록 방지 정책으로 인해 **전혀 동작하지 않음**

**원인 (실험으로 확인)**: 동일 이름의 `@mcp.tool()` 데코레이터가 두 번 등록될 때 FastMCP 1.27.0은 첫 번째 등록을 유지하고 두 번째는 WARNING만 출력한 후 무시함

```
WARNING  Tool already exists: foo     ← FastMCP 1.27.0 실제 출력
registered tools: ['foo']
foo is async? False                   ← 첫 번째(직접 구현) 유지 확인
```

**수정 전 코드 예시**:
```python
# 498줄 — 첫 등록 (직접 구현) → 이것이 실제 동작
@mcp.tool()
def search_codebase(query: str, top_k: int = 6) -> str:
    return _search(COL_CODEBASE, query, top_k)

# 1401줄 — 두 번째 등록 (워커 포워딩) → 절대 호출 안 됨
@mcp.tool()
async def search_codebase(query: str, top_k: int = 6) -> str:
    worker = await process_manager.get_worker("search_codebase")
    return await _forward_request(worker.port, ...)
```

**영향**: 모든 요청이 server.py 단일 프로세스에서 직접 실행됨. RAG/NL2SQL/P&ID를 별도 워커로 분리한다는 설계 의도가 완전히 무의미함.

**중복 등록된 tool 목록 (15개, WARNING 15회)**:

> 최초 진단은 "14개"로 기록했으나 실제 카운트는 15개. `query_with_nl`은 두 번째 섹션에 없어 중복 아님. `get_worker_status`는 두 번째 섹션에만 있어 중복 아님.

| Tool 이름 | 첫 등록(실제 동작) | 두 번째 등록(dead) |
|-----------|---------------------|---------------------|
| `search_codebase` | 498-510줄 (직접) | 1400-1407줄 (워커) |
| `search_r530_docs` | 513-525줄 (직접) | 1410-1417줄 (워커) |
| `ask_iiot_llm` | 528-555줄 (직접) | 1420-1427줄 (워커) |
| `rag_query` | 557-574줄 (직접) | 1430-1438줄 (워커) |
| `run_sql` | 605-615줄 (직접) | 1441-1445줄 (워커) |
| `query_pv_history` | 618-658줄 (직접) | 1448-1457줄 (워커) |
| `get_tag_metadata` | 661-693줄 (직접) | 1460-1468줄 (워커) |
| `list_drawings` | 696-726줄 (직접) | 1471-1477줄 (워커) |
| `extract_pid_tags` | 814-919줄 (직접) | 1519-1535줄 (워커) |
| `match_pid_tags` | 922-990줄 (직접) | 1538-1550줄 (워커) |
| `parse_pid_dxf` | 996-1102줄 (직접) | 1480-1489줄 (워커) |
| `parse_pid_pdf` | 1105-1215줄 (직접) | 1492-1504줄 (워커) |
| `parse_pid_drawing` | 1340-1374줄 (직접) | 1507-1516줄 (워커) |
| `build_pid_graph_parallel` | 1218-1318줄 (직접) | 1553-1562줄 (워커) |
| `analyze_pid_impact` | 1320-1338줄 (직접) | 1565-1578줄 (워커) |

단독 등록 (중복 없음):
- `query_with_nl`: 728-809줄만 존재
- `get_worker_status`: 1581-1592줄만 존재 (항상 `{}` 반환 — workers가 비어있으므로)

---

### 1.2 [HIGH] async 함수 await 누락 — 7개 함수 ✅ 수정 완료

> 최초 진단은 MEDIUM으로 분류하고 3개 함수("코드 구조가 혼란스러움")만 언급했으나,
> 실제로는 **런타임 오류**이며 RAG 3개 + DB 4개 = 총 7개.

**공통 원인**: `async def` 함수를 sync `def` 내에서 `await` 없이 호출하면 코루틴 객체가 반환됨.
- `_get_db_connection()` 누락: `.cursor()` 호출 시 `AttributeError`
- `_search()` 누락: FastMCP가 코루틴 객체를 `str()`로 직렬화 → `"<coroutine object _search at 0x...>"` 반환

**케이스 A — RAG 도구 3개 (코루틴 repr 반환)**

`search_codebase`, `search_r530_docs`, `rag_query`: `def`(sync)에서 `async def _search()`를 await 없이 호출.

```python
# 수정 전 — MCP 응답이 "<coroutine object _search at 0x...>"
@mcp.tool()
def search_codebase(query: str, top_k: int = 6) -> str:
    return _search(COL_CODEBASE, query, top_k)   # 코루틴 객체 반환

# rag_query: f-string 안에서 호출 → 코루틴 repr이 문자열로 삽입
context_parts.append(f"...\n{_search(COL_OPC_DOCS, question, 4)}")
```

`ask_iiot_llm`은 sync `_llm()` 클라이언트 사용이므로 해당 없음.

**케이스 B — `_get_db_connection()` await 누락: 즉시 크래시 (3개 함수)**

`query_pv_history`, `get_tag_metadata`, `list_drawings`: `def`(sync)로 정의되어 있어 호출 즉시 크래시.

```python
@mcp.tool()
def query_pv_history(...) -> str:        # sync def
    conn = _get_db_connection()          # await 없음 → 코루틴 객체
    with conn.cursor() as cur:           # AttributeError: 'coroutine' has no 'cursor'
```

**케이스 C — `build_pid_graph_parallel` (무음 실패)**

`async def`이지만 내부 중첩 sync 함수 `_fetch_system_tags`에서 `_get_db_connection()` 호출.
`try/except`가 AttributeError를 삼켜 함수 자체는 크래시하지 않지만, `system_tags`가 항상 `[]`로 폴백되어 **P&ID 매핑이 태그 정보 없이 실행됨**.

**영향 요약**:

| 함수 | 결과 |
|------|------|
| `search_codebase` | `"<coroutine object _search at 0x...>"` 반환 |
| `search_r530_docs` | `"<coroutine object _search at 0x...>"` 반환 |
| `rag_query` | context에 코루틴 repr 삽입 → LLM이 쓰레기 컨텍스트로 답변 |
| `query_pv_history` | 호출 즉시 AttributeError 크래시 |
| `get_tag_metadata` | 호출 즉시 AttributeError 크래시 |
| `list_drawings` | 호출 즉시 AttributeError 크래시 |
| `build_pid_graph_parallel` | 크래시 없지만 system_tags 항상 `[]` → 매핑 품질 저하 |

`run_sql`은 `async def`로 올바르게 정의되어 있어 이 문제 없음.

```python
# 252줄
async def _get_db_connection():          # ← async def
    return await asyncio.to_thread(_connect)
```

**케이스 A — 즉시 크래시 (3개 함수)**

`query_pv_history`, `get_tag_metadata`, `list_drawings`: `def`(sync)로 정의되어 있어 호출 즉시 크래시.

```python
@mcp.tool()
def query_pv_history(...) -> str:        # sync def
    conn = _get_db_connection()          # await 없음 → 코루틴 객체
    with conn.cursor() as cur:           # AttributeError: 'coroutine' has no 'cursor'
```

**케이스 B — 무음 실패 (1개 함수)**

`build_pid_graph_parallel`: `async def`이지만 내부 중첩 sync 함수 `_fetch_system_tags`에서 호출.

```python
@mcp.tool()
async def build_pid_graph_parallel(filepath: str) -> str:
    system_tags = []
    try:
        def _fetch_system_tags():              # sync 함수 (to_thread 용)
            conn = _get_db_connection()        # await 없음 → 코루틴 객체
            with conn.cursor() as cur:         # AttributeError
                ...
        system_tags = await asyncio.to_thread(_fetch_system_tags)
    except Exception as e:
        logging.warning(f"Failed to fetch system tags: {e}")  # 예외를 삼킴
```

`try/except`가 AttributeError를 삼켜 함수 자체는 크래시하지 않지만, `system_tags`가 항상 `[]`로 폴백되어 **P&ID 매핑이 태그 정보 없이 실행됨**.

**영향 요약**:

| 함수 | 결과 |
|------|------|
| `query_pv_history` | 호출 즉시 AttributeError 크래시 |
| `get_tag_metadata` | 호출 즉시 AttributeError 크래시 |
| `list_drawings` | 호출 즉시 AttributeError 크래시 |
| `build_pid_graph_parallel` | 크래시 없지만 system_tags 항상 `[]` → 매핑 품질 저하 |

`run_sql`은 `async def`로 올바르게 정의되어 있어 이 문제 없음.

---

### 1.3 [MEDIUM] Dead Code 부피 ✅ 수정 완료

워커 아키텍처 관련 dead code 총 **약 367줄**:

| 영역 | 라인 | 줄 수 | 내용 |
|------|------|-------|------|
| `WorkerProcess` dataclass | 62-67 | ~6줄 | 워커 프로세스 데이터 구조 |
| `ProcessManager` 클래스 | 69-197 | ~129줄 | 워커 프로세스 관리 (시작/종료/헬스체크) |
| `process_manager` 전역 인스턴스 | 201 | 1줄 | `ProcessManager()` |
| `_forward_request` | 1377-1395 | ~19줄 | 워커 HTTP 포워딩 |
| 워커 포워딩 래퍼 15개 | 1398-1578 | ~180줄 | `@mcp.tool()` 중복 등록 |
| `get_worker_status` | 1581-1592 | ~12줄 | 워커 상태 조회 (항상 `{}`) |
| 불필요한 top-level import | 52-57 | ~6줄 | `subprocess`, `atexit`, `signal`, `dataclass`, `Dict`, `Optional`, `cache` |

---

### 1.4 [LOW] worker/ 디렉토리 유지보수 문제 (미수정 — 호출 경로 없음)

`worker/rag_worker.py`, `worker/nl2sql_worker.py`, `worker/pid_worker.py`는 현재 어떤 경로로도 호출되지 않음.
server.py 정리 후 완전한 dead code가 됨. 내부 버그 목록은 참고용으로만 보존:

| 파일 | 버그 |
|------|------|
| `rag_worker.py` | 응답 형식이 server.py와 다름 (`{"success": true, "items": [...]}` vs JSON string) |
| `nl2sql_worker.py` | `query_pv_history`에서 `time_bucket('1 min', ts)` 사용 — `time_bucket` 금지, `ts` 컬럼 없음 (실제: `recorded_at`) |
| `nl2sql_worker.py` | `get_tag_metadata`에서 `tag_name`, `unit`, `description` 조회 — `unit`/`description` 컬럼 실제 테이블에 없음 |
| `nl2sql_worker.py` | `list_drawings`에서 `dict(zip(columns, row[0]))` — `row[0]`은 문자열이므로 첫 글자만 매핑됨 |
| `pid_worker.py` | `build_pid_graph_parallel`가 server.py 직접 구현과 완전히 다른 독립 병렬 아키텍처로 구현됨 |

---

## 2. 수정 내용

### 2.1 server.py 수정 (1608줄 → 1241줄, -367줄)

#### (A) 불필요한 import 제거 및 `import os` 상단 이동

```python
# 수정 전
import os           # 44-57줄 내 위치 (설정 섹션 아래)
import subprocess
import atexit
import signal
from dataclasses import dataclass
from typing import Dict, Optional
from functools import cache

# 수정 후 — 상단 imports 블록으로 이동, 불필요한 것 제거
import os           # 상단으로 이동 (설정 섹션 env var 사용 위해)
import asyncio
# subprocess/atexit/signal/dataclass/Dict/Optional/cache 삭제
```

`subprocess`는 `_convert_dwg_to_dxf_dxflib` 내부에서 로컬 import로 사용하므로 top-level 제거만 필요.

#### (B) 설정값 환경변수화

```python
# 수정 전 — 하드코딩
QDRANT_URL      = "http://localhost:6333"
OLLAMA_URL      = "http://localhost:11434"
EMBED_MODEL     = "nomic-embed-text"
VLLM_BASE_URL   = "http://localhost:8000/v1"
VLLM_MODEL      = "Qwen3.6-27B-FP8"
DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform"
DB_TIMEOUT = 10

# 수정 후 — 환경변수 (worker/*.py와 동일한 방식)
QDRANT_URL      = os.environ.get("QDRANT_URL",    "http://localhost:6333")
OLLAMA_URL      = os.environ.get("OLLAMA_URL",    "http://localhost:11434")
EMBED_MODEL     = os.environ.get("EMBED_MODEL",   "nomic-embed-text")
VLLM_BASE_URL   = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL      = os.environ.get("VLLM_MODEL",    "Qwen3.6-27B-FP8")
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
```

#### (C) ProcessManager 전체 삭제

`WorkerProcess` dataclass, `ProcessManager` 클래스, `process_manager = ProcessManager()` 전체 제거.

#### (D) `_get_db_connection()` await 누락 수정 (4개)

**케이스 A — RAG 도구 3개**: `def` → `async def` + `await` 추가

```python
@mcp.tool()
async def search_codebase(query: str, top_k: int = 6) -> str:
    return await _search(COL_CODEBASE, query, top_k)

@mcp.tool()
async def rag_query(question: str, ...) -> str:
    context_parts.append(f"...\n{await _search(COL_OPC_DOCS, question, 4)}")
    return ask_iiot_llm(question, ...)   # ask_iiot_llm은 sync이므로 await 불필요
```

**케이스 B — NL2SQL 함수 3개**: `def` → `async def` 변경 + `await` 추가

```python
@mcp.tool()
async def query_pv_history(...) -> str:
    conn = await _get_db_connection()
```

`get_tag_metadata`, `list_drawings`도 동일하게 적용.

**케이스 C — `build_pid_graph_parallel`**: `asyncio.to_thread` 내 중첩 sync 함수 제거, async 컨텍스트에서 직접 `await`

```python
# 수정 전 — system_tags 항상 [] (무음 실패)
system_tags = []
try:
    def _fetch_system_tags():
        conn = _get_db_connection()      # await 없음 → AttributeError → except에서 삼킴
        ...
    system_tags = await asyncio.to_thread(_fetch_system_tags)
except Exception as e:
    logging.warning(...)

# 수정 후 — async 컨텍스트에서 직접 await
system_tags = []
try:
    conn = await _get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute("SELECT tagname FROM realtime_table")
            system_tags = [r[0] for r in cur.fetchall()]
    finally:
        conn.close()
except Exception as e:
    logging.warning(f"Failed to fetch system tags: {e}")
```

#### (E) 워커 포워딩 섹션 전체 삭제

`_forward_request`, 워커 포워딩 래퍼 15개, `get_worker_status` 삭제.

#### 수정 결과

| 항목 | 수정 전 | 수정 후 |
|------|---------|---------|
| 총 줄 수 | 1608줄 | 1241줄 |
| `@mcp.tool()` 등록 수 | 32개 | 16개 |
| FastMCP 시작 시 WARNING | 15회 | 0회 |
| `search_codebase` 호출 결과 | 코루틴 repr 문자열 반환 | 정상 검색 결과 반환 |
| `search_r530_docs` 호출 결과 | 코루틴 repr 문자열 반환 | 정상 검색 결과 반환 |
| `rag_query` 호출 결과 | 쓰레기 컨텍스트로 LLM 답변 | 정상 RAG 답변 |
| `query_pv_history` 호출 결과 | AttributeError 크래시 | 정상 동작 |
| `get_tag_metadata` 호출 결과 | AttributeError 크래시 | 정상 동작 |
| `list_drawings` 호출 결과 | AttributeError 크래시 | 정상 동작 |
| `build_pid_graph_parallel` system_tags | 항상 `[]` (무음 실패) | DB에서 실제 태그 로드 |

---

### 2.2 Claude Code 연결 — `.mcp.json` 신규 생성

> 최초 개선 방안의 "방안 B: server_stdio.py 신규 생성"은 불필요. server.py가 이미 `--http` 플래그 없이 실행하면 stdio 모드로 동작함 (파일 주석에도 명시되어 있었음).

`server.py` 기존 엔트리포인트:
```python
if __name__ == "__main__":
    if "--http" in sys.argv:
        mcp.run(transport="streamable-http")  # C# McpClient용
    else:
        mcp.run(transport="stdio")            # Claude Code / Roo Code MCP용 ← 이미 있음
```

신규 파일: `/home/windpacer/projects/ExperionCrawler/.mcp.json`
```json
{
  "mcpServers": {
    "iiot-rag": {
      "command": "uv",
      "args": ["run", "--directory", "mcp-server", "python", "server.py"],
      "type": "stdio"
    }
  }
}
```

---

### 2.3 opencode 연결 — `~/.config/opencode/opencode.json` mcp 섹션 추가

> 최초 개선 방안의 "방안 A: Remote HTTP (권장)"은 현재 server.py가 `json_response=True, stateless_http=True`로 설정되어 있어 C# HttpClient 전용 비표준 모드이므로 opencode MCP 클라이언트와의 호환성이 불확실함. stdio local 방식이 더 확실함.

`~/.config/opencode/opencode.json`에 `mcp` 섹션 추가:
```json
{
  "$schema": "https://opencode.ai/config.json",
  "provider": { ... },
  "model": "vllm_node/Qwen3.6-27B-FP8",
  "mcp": {
    "iiot-rag": {
      "type": "local",
      "command": ["uv", "run", "--directory", "/home/windpacer/projects/ExperionCrawler/mcp-server", "python", "server.py"],
      "enabled": true
    }
  }
}
```

**경로 참고**: `~/.config/opencode/opencode.json`은 전역 설정 파일이므로 `--directory`에 절대 경로를 사용했음. 프로젝트를 다른 위치로 이동하면 이 경로를 수동으로 수정해야 함. `.mcp.json`(Claude Code용)은 프로젝트 루트에 위치하므로 상대 경로(`--directory mcp-server`)를 사용해 이동에 영향 없음.

| 파일 | 위치 | 경로 방식 |
|------|------|-----------|
| `.mcp.json` | 프로젝트 루트 | 상대 경로 (`--directory mcp-server`) — 이동에 강건 |
| `opencode.json` | `~/.config/opencode/` (전역) | 절대 경로 필수 — 이동 시 수정 필요 |

---

## 3. 최초 진단 오류 정리

| 항목 | 최초 진단 | 실제 |
|------|-----------|------|
| 중복 tool 수 | 14개 | **15개** (`query_with_nl` 제외한 나머지 15개) |
| 1.2 대상 함수 | 3개 (`query_pv_history`, `get_tag_metadata`, `list_drawings`) | **7개** (RAG 3개 + DB 4개 전부 누락) |
| 1.2 심각도 | MEDIUM ("구조 혼란") | **HIGH (크래시 또는 무음 실패)** |
| 1.2 설명 | "psycopg.connect()를 직접 호출하는 별도 코드로 동작" | **틀림 — 별도 코드 없음, 무조건 AttributeError** |
| Phase 2 권장 방안 | 방안 A (Remote HTTP) | **불확실 — json_response=True 호환성 미검증** |
| 방안 B | server_stdio.py 신규 생성 | **불필요 — server.py가 이미 stdio 지원** |
| ProcessManager dead code | 59-197줄 (~140줄) | **62-201줄 (~143줄, process_manager 인스턴스 포함)** |
| opencode.json 경로 | 미언급 | **절대 경로 사용 — 프로젝트 이동 시 수동 수정 필요** |

---

## 4. worker/ 디렉토리 향후 처리

현재 `worker/` 디렉토리는 어떤 경로로도 호출되지 않는 완전한 dead code.

**권장**: 삭제. 재구현이 필요한 경우 아래 제약 사항을 반드시 준수:
- FastMCP 동일 이름 tool 중복 등록 불가 → 워커 포워딩 래퍼에 다른 이름 사용하거나 server.py에서 조건부 분기
- nl2sql_worker.py 재작성 시 실제 스키마 기준: `tagname` (not `tag_name`), `recorded_at` (not `ts`), `time_bucket` 사용 금지
- 외부 서비스(vLLM, Qdrant, PG)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음
