# 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()`로 직렬화 → `""` 반환 **케이스 A — RAG 도구 3개 (코루틴 repr 반환)** `search_codebase`, `search_r530_docs`, `rag_query`: `def`(sync)에서 `async def _search()`를 await 없이 호출. ```python # 수정 전 — MCP 응답이 "" @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` | `""` 반환 | | `search_r530_docs` | `""` 반환 | | `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)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음