Files
ExperionCrawler/mcp-server-진단-문제점-개선방안.md
2026-05-09 04:28:10 +09:00

18 KiB

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                   ← 첫 번째(직접 구현) 유지 확인

수정 전 코드 예시:

# 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 없이 호출.

# 수정 전 — 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)로 정의되어 있어 호출 즉시 크래시.

@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_sqlasync def로 올바르게 정의되어 있어 이 문제 없음.

# 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)로 정의되어 있어 호출 즉시 크래시.

@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에서 호출.

@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_sqlasync 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 상단 이동

# 수정 전
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) 설정값 환경변수화

# 수정 전 — 하드코딩
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개: defasync def + await 추가

@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개: defasync def 변경 + await 추가

@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

# 수정 전 — 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 기존 엔트리포인트:

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

{
  "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.jsonmcp 섹션 추가:

{
  "$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)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음