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_sql은 async 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_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 상단 이동
# 수정 전
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개: def → async 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개: def → async 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.json에 mcp 섹션 추가:
{
"$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(nottag_name),recorded_at(notts),time_bucket사용 금지 - 외부 서비스(vLLM, Qdrant, PG)가 병목이므로 워커 분리 성능 이득은 미미 — 복잡도 대비 효과 낮음